Repository navigation
Use freelist for range object, iterator objects and other often used objects #126703
Description
Feature or enhancement
Proposal:
We can add freelists for the range object and various iter objects to improve performance. Using the new methods from #121934 the amount of code needed for adding a freelist is quite small. A prototype implementation is here:
main...eendebakpt:cpython:iter_freelists
main...eendebakpt:cpython:range_freelist
@markshannon In faster-cpython/ideas#132 (comment) you noted that freelists should be used for all types. Is adding freelists in a similar style to the already existing freelists a good approach? We could also try to make a more generic construction (for example inside PyObject_New/PyObject_Free), but that would have a much larger impact and I cannot oversee all consequences.
The freelists from the prototype improve performance of range in microbenchmarks:
range(1): Mean +- std dev: [rp_main] 50.0 ns +- 0.4 ns -> [rp_pr] 40.6 ns +- 0.5 ns: 1.23x faster
iter(range(1)): Mean +- std dev: [rp_main] 91.7 ns +- 2.4 ns -> [rp_pr] 68.5 ns +- 0.7 ns: 1.34x faster
list(range(1)): Mean +- std dev: [rp_main] 165 ns +- 1 ns -> [rp_pr] 144 ns +- 1 ns: 1.15x faster
list(iter(range(1))): Mean +- std dev: [rp_main] 240 ns +- 17 ns -> [rp_pr] 229 ns +- 13 ns: 1.05x faster
range(2, 1): Mean +- std dev: [rp_main] 61.6 ns +- 0.6 ns -> [rp_pr] 46.7 ns +- 0.4 ns: 1.32x faster
iter(range(2, 1)): Mean +- std dev: [rp_main] 100 ns +- 4 ns -> [rp_pr] 79.6 ns +- 4.0 ns: 1.26x faster
list(iter(range(2, 1))): Mean +- std dev: [rp_main] 227 ns +- 3 ns -> [rp_pr] 209 ns +- 4 ns: 1.08x faster
for loop length 1: Mean +- std dev: [rp_main] 146 ns +- 4 ns -> [rp_pr] 126 ns +- 6 ns: 1.16x faster
range(10): Mean +- std dev: [rp_main] 51.7 ns +- 2.4 ns -> [rp_pr] 41.0 ns +- 0.9 ns: 1.26x faster
iter(range(10)): Mean +- std dev: [rp_main] 92.7 ns +- 3.5 ns -> [rp_pr] 68.3 ns +- 2.2 ns: 1.36x faster
list(range(10)): Mean +- std dev: [rp_main] 205 ns +- 4 ns -> [rp_pr] 184 ns +- 9 ns: 1.11x faster
list(iter(range(10))): Mean +- std dev: [rp_main] 279 ns +- 7 ns -> [rp_pr] 261 ns +- 5 ns: 1.07x faster
range(2, 10): Mean +- std dev: [rp_main] 57.7 ns +- 1.5 ns -> [rp_pr] 48.5 ns +- 2.2 ns: 1.19x faster
iter(range(2, 10)): Mean +- std dev: [rp_main] 101 ns +- 3 ns -> [rp_pr] 78.1 ns +- 1.1 ns: 1.29x faster
list(iter(range(2, 10))): Mean +- std dev: [rp_main] 284 ns +- 17 ns -> [rp_pr] 262 ns +- 10 ns: 1.08x faster
for loop length 10: Mean +- std dev: [rp_main] 329 ns +- 7 ns -> [rp_pr] 295 ns +- 5 ns: 1.12x faster
range(100): Mean +- std dev: [rp_main] 52.3 ns +- 2.2 ns -> [rp_pr] 41.1 ns +- 1.3 ns: 1.27x faster
iter(range(100)): Mean +- std dev: [rp_main] 91.7 ns +- 3.5 ns -> [rp_pr] 69.6 ns +- 2.1 ns: 1.32x faster
list(range(100)): Mean +- std dev: [rp_main] 653 ns +- 27 ns -> [rp_pr] 603 ns +- 29 ns: 1.08x faster
list(iter(range(100))): Mean +- std dev: [rp_main] 701 ns +- 17 ns -> [rp_pr] 671 ns +- 17 ns: 1.04x faster
range(2, 100): Mean +- std dev: [rp_main] 58.5 ns +- 2.8 ns -> [rp_pr] 48.1 ns +- 2.3 ns: 1.22x faster
iter(range(2, 100)): Mean +- std dev: [rp_main] 99.6 ns +- 1.5 ns -> [rp_pr] 78.4 ns +- 0.8 ns: 1.27x faster
list(iter(range(2, 100))): Mean +- std dev: [rp_main] 710 ns +- 28 ns -> [rp_pr] 674 ns +- 16 ns: 1.05x faster
for loop length 100: Mean +- std dev: [rp_main] 2.06 us +- 0.07 us -> [rp_pr] 1.98 us +- 0.11 us: 1.04x faster
range(400): Mean +- std dev: [rp_main] 71.3 ns +- 2.2 ns -> [rp_pr] 61.7 ns +- 5.1 ns: 1.16x faster
iter(range(400)): Mean +- std dev: [rp_main] 112 ns +- 5 ns -> [rp_pr] 89.5 ns +- 2.8 ns: 1.25x faster
list(iter(range(400))): Mean +- std dev: [rp_main] 3.88 us +- 0.18 us -> [rp_pr] 3.77 us +- 0.08 us: 1.03x faster
range(2, 400): Mean +- std dev: [rp_main] 79.3 ns +- 1.7 ns -> [rp_pr] 66.0 ns +- 3.7 ns: 1.20x faster
iter(range(2, 400)): Mean +- std dev: [rp_main] 119 ns +- 2 ns -> [rp_pr] 99.5 ns +- 3.1 ns: 1.19x faster
for loop length 400: Mean +- std dev: [rp_main] 12.1 us +- 0.7 us -> [rp_pr] 10.9 us +- 0.4 us: 1.11x faster
Benchmark hidden because not significant (2): list(range(400)), list(iter(range(2, 400)))
Geometric mean: 1.16x faster
Benchmark script
import pyperf
runner = pyperf.Runner()
loop = """
def g(n):
x=0
for ii in range(n):
x += 1
"""
for s in [1, 10, 100, 400]:
time = runner.timeit(name=f'range({s})', stmt=f"range({s})")
time = runner.timeit(name=f'iter(range({s}))', stmt=f"iter(range({s}))")
time = runner.timeit(name=f'list(range({s}))', stmt=f"list(range({s}))")
time = runner.timeit(name=f'list(iter(range({s})))', stmt=f"list(iter(range({s})))")
time = runner.timeit(name=f'range(2, {s})', stmt=f"range(2, {s})")
time = runner.timeit(name=f'iter(range(2, {s}))', stmt=f"iter(range(2, {s}))")
time = runner.timeit(name=f'list(iter(range(2, {s})))', stmt=f"list(iter(range(2, {s})))")
time = runner.timeit(name=f'for loop length {s}', stmt=f"g({s})", setup=loop)
``
</details>
On the pyperformance test suite (actually, a subset of the suite, not all benchmarks run on my system) shows the percentage of successfull freelist allocations increases about 1%.
Main:
Allocations from freelist 2,004,971,371 39.8%
Frees to freelist 2,005,350,418
Allocations 3,034,877,938 60.2%
Allocations to 512 bytes 3,008,791,812 59.7%
Allocations to 4 kbytes 18,648,072 0.4%
Allocations over 4 kbytes 7,438,054 0.1%
Frees 3,142,033,922
PR
Allocations from freelist 2,064,126,652 40.8%
Frees to freelist 2,064,499,538
Allocations 2,989,239,063 59.2%
Allocations to 512 bytes 2,963,207,194 58.6%
Allocations to 4 kbytes 18,596,157 0.4%
Allocations over 4 kbytes 7,435,712 0.1%
Frees 3,096,349,170
(Some quick testing shows that most of the allocations not via freelists are due to python int objects, so that is another candidate to use freelists for)
### Has this already been discussed elsewhere?
No response given
### Links to previous discussion of this feature:
Some references to discussions on freelists:
* https://github.com/python/cpython/pull/121934
* https://github.com/faster-cpython/ideas/discussions/132
* https://github.com/python/cpython/issues/91912
* https://github.com/python/cpython/pull/101453
<!-- gh-linked-prs -->
### Linked PRs
* gh-128368
* gh-128592
* gh-128594
* gh-128619
* gh-128692
* gh-129638
* gh-129921
* gh-132319
* gh-135233
<!-- /gh-linked-prs -->
Activity
Fidget-Spinner commentedon Nov 13, 2024
I would be willing to shepherd/champion such a PR. Back in the day we chose not to do this presumably because the added code complexity was not worth it. However, with Sam's consistent implementation of freelists, that point is now moot. Freelists are really simple to add mostly.
However, may I suggest trying first with medium ints? We can re-benchmark pyperformance with it. If you're not interested, just let me know and I can take over for medium ints.
eendebakpt commentedon Nov 13, 2024
I started with
rangebecause the ints are more complex (either do only the medium ints, or use a more complex scheme with a size based freelists).I already have a working branch for the medium int case, see main...eendebakpt:cpython:int_freelist. Once I have some more benchmarks and statistics I will open a PR.
[-]Use freelist for range and range_iter objects[/-][+]Use freelist for range and iter objects[/+]eendebakpt commentedon Dec 21, 2024
Also for iter objects for list and tuple we can gain performance in microbenchmarks:
Script:
eendebakpt commentedon Dec 30, 2024
To determine for which objects a freelist should be added (or removed) some statistics have been gathered. The data is gathered by adding some additional statistics functionality (branch: main...eendebakpt:cpython:wip_stats) and running the pyperformance benchmarks (minus some benchmarks that do not run on my system).
Most object allocations are tracked via generic calls like
_PyObject_Newor_PyObject_NewVar(using thetype->tp_name). Some objects allocate memory allocated directly (e.g. lists and tuples), for these objects manually statistics have been added. Not all objects have been added manually, so a few will be missing from the statistics.Here are the results on the types most often allocated and the number of freelist allocations:
Some observations:
Most allocations are of type
int(via_PyLong_New). There is already a freelist for compacts ints that is working well (the ratio allocations/freelist is about 1:4). There is a large number of allocations with 2 or 3 digits (not covered by the freelist). So perhaps extending the freelist to cover ints with digits of size 2 (and 3) is worthwhile. Note that most hash values are ints with 2 digits. There are still some allocations of ints with 1 digit. Perhaps the freelist size can be increased, or bulk allocation of freelist objects (see Use a principled, and consistent, implementation of freelists. faster-cpython/ideas#132)Also high in the number of allocations are iterators (tuple_iter, list_iter, range_iterator, PySeqIter_new). Adding freelists is relatively easy. Perhaps we can combine the freelist for these iterators (we then need to update the type, but otherwise they can be shared). A prototype of a shared freelist for different iterators is in main...eendebakpt:cpython:iter_freelists
For the iterators the freelist size can probably be small.
There are many allocations for types
builtin_function_or_method,generator,methodandfunction. For themethodadding a freelist is standard (inObjects/classobject.c). Thebuiltin_function_or_methodhappens often (for example a new object of this type is generated when for a listx=[1,2]one callsx.append). The adaptive interpreter seems to reduce the number ofbuiltin_function_or_methodobjects significantly, but still we could add a freelist. This requires some thought, as thePyCMethod_Newcan either allocate for aPyCMethodObjector aPyCFunctionObject, seecpython/Objects/methodobject.c
Lines 81 to 105 in 71de839
The
functionandgeneratorobjects don't seem to fit standard freelist at first glance.The
StopIterationis in the top stats. Seems like a good candidate for a freelist, together withIndexError. The implementation (inObjects/exceptions.c) uses macros to define several variations of exceptions. Maybe they can be combined in a freelist, but this requires some more thinking.There is the
_safe_keyobject showing up. This is probably from pprint. Is that a performance bottleneck? Maybe not, it is a python class so overhead of allocation is probably small. Not sure which benchmark is triggering this onetupleandlistare still allocated a lot. However, the 8 milliontupleallocations seem okay with 625 million freelist results fromtuple!There are some objects specific for pyperformance benchmarks (e.g. the
Vectorclass from bm_raytrace andGVectorclass from bm_chaos). AlsoFractionis there. Perhaps we can create a freelist for thePyType_GenericAllocbased on the size? Code is here: https://github.com/python/cpython/blob/71de839ec987bb67b98fcfecfc687281841a713c/Objects/typeobject.c#L2213C1-L2213C21?For lists we can use a size based freelist (just as for tuple). The reason for doing this would be to avoid having to allocate/deallocate the space for the list items on each freelist pop/push. Data suggest that most list allocations happen for < 20 items (see figure below). One issue with lists is that they can be resized. So perhaps we are allocating many lists of small size, but when deallocating the lists can be larger.

Fidget-Spinner commentedon Dec 31, 2024
Wow, excellent investigative work!
[-]Use freelist for range and iter objects[/-][+]Use freelist for range object, iterator objects and other often used objects[/+]gh-126703: Add freelist for `PyMethodObject` (#128594)
gh-126703: Add freelists for list and tuple iterators (GH-128592)
gh-126703: Add PyCFunction freelist (GH-128692)
gh-126703: Add freelist for range and range_iter objects (GH-128619)
gh-126703: Fix possible use after free in pycfunction freelist (GH-13…
pythongh-126703: add freelist for PyComplexObject's
gh-126703: Add freelist for PyComplexObject's (gh-135233)
pythongh-126703: Add freelist for PyComplexObject's (pythongh-135233)
pythongh-126703: Add freelist for PyComplexObject's (pythongh-135233)
pythongh-126703: Add freelist for PyComplexObject's (pythongh-135233)
pythongh-126703: Add freelist for PyComplexObject's (pythongh-135233)
eendebakpt commentedon Apr 28, 2026
Closing as the most important PRs have been merged. There are still a couple open, they can be decided upon by a core dev (probably after rebasing at this stage).
pythongh-126703: Bucket ints by their plain digit count in _PyLong_Ex…
pythongh-126703: Tighten x_add/x_mul size tests, single NEWS entry