Repository navigation
Potentially serious regression in garbage collector performance in Python 3.14 #139951
Copy link
Copy link
Closed
Closed
Copy link
Labels
3.14bugs and security fixesbugs and security fixes3.15bugs and security fixesbugs and security fixesinterpreter-core(Objects, Python, Grammar, and Parser dirs)(Objects, Python, Grammar, and Parser dirs)performancePerformance or resource usagePerformance or resource usagetype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error
Description
Bug report
Bug description:
The following code, consisting of a bunch of dict lookups and writes, runs in
- < 1 second on Python 3.13
- > 2 seconds on Python 3.14
n = 1000
d = {}
for i in range(n):
d[(i, i)] = d[(i, 0)] = d[(0, i)] = 0
for i in range(1, n):
for j in range(1, n):
d[(i, j)] = i + j + d[(i - 1, j)] + d[(i, j - 1)] + d[(i - 1, j - 1)]
print(d[(n - 1, n - 1)])Tests are run on a Dell Precision 5490, running Ubuntu:
Linux precision 6.14.0-33-generic #33-Ubuntu SMP PREEMPT_DYNAMIC Wed Sep 17 23:22:02 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux
Ubuntu 25.04 (Plucky)
The cpython is from python-build-standalone
cpython-3.14.0+20251010-x86_64-unknown-linux-gnu-install_only.tar.gzcpython-3.13.8+20251010-x86_64-unknown-linux-gnu-install_only.tar.gz
Log
[~/regression-testing]$ hyperfine --warmup=10 "cp313/python/bin/python3.13 dicttest.py" "cp314/python/bin/python3.14 dicttest.py"
Benchmark 1: cp313/python/bin/python3.13 dicttest.py
Time (mean ± σ): 880.8 ms ± 119.7 ms [User: 730.1 ms, System: 150.1 ms]
Range (min … max): 713.3 ms … 1066.2 ms 10 runs
Benchmark 2: cp314/python/bin/python3.14 dicttest.py
Time (mean ± σ): 2.235 s ± 0.035 s [User: 2.066 s, System: 0.168 s]
Range (min … max): 2.168 s … 2.295 s 10 runs
Summary
cp313/python/bin/python3.13 dicttest.py ran
2.54 ± 0.35 times faster than cp314/python/bin/python3.14 dicttest.py
It seems to scale very poorly too. With the same code, but n = 2000 (instead of n = 1000), leads to 3.3 sec (Python 3.13) vs 20 seconds (Python 3.14)! I.e., Python 3.13 being 5 times faster.
[~/regression-testing]$ hyperfine --warmup=10 "cp313/python/bin/python3.13 dicttest.py" "cp314/python/bin/python3.14 dicttest.py"
Benchmark 1: cp313/python/bin/python3.13 dicttest.py
Time (mean ± σ): 3.458 s ± 0.048 s [User: 2.889 s, System: 0.568 s]
Range (min … max): 3.375 s … 3.521 s 10 runs
Benchmark 2: cp314/python/bin/python3.14 dicttest.py
Time (mean ± σ): 20.084 s ± 0.230 s [User: 19.503 s, System: 0.577 s]
Range (min … max): 19.749 s … 20.345 s 10 runs
Summary
cp313/python/bin/python3.13 dicttest.py ran
5.81 ± 0.10 times faster than cp314/python/bin/python3.14 dicttest.py
Plotting time (in seconds) of the script run with n = 0 ... 2000.
CPython versions tested on:
3.14
Operating systems tested on:
Linux
Linked PRs
- gh-139951: Do not track immutable tuples in GC #140204
- GH-139951: Fix major GC performance regression #140262
- gh-139951: Test on GC collection disabled if threshold is zero #140304
- [3.14] gh-139951: Test on GC collection disabled if threshold is zero (GH-140304) #140362
- [3.13] gh-139951: Test on GC collection disabled if threshold is zero (GH-140304) #140363
- [3.14] GH-139951: Fix major GC performance regression (GH-140262) #140423
- [3.14] GH-139951: Fix major GC performance regression. Backport of GH-140262 #140447
- gh-139951: Tests on tuple tracking #140575
- [3.14] gh-139951: Tests on tuple tracking (GH-140575) #140701
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
3.14bugs and security fixesbugs and security fixes3.15bugs and security fixesbugs and security fixesinterpreter-core(Objects, Python, Grammar, and Parser dirs)(Objects, Python, Grammar, and Parser dirs)performancePerformance or resource usagePerformance or resource usagetype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error
Activity
picnixz commentedon Oct 11, 2025
This looks similar to #137636 and the issue was PGO magic. The issue was explicitly for
dict[int, int]and hash collisions algorithm.chore: officially support Python 3.14; fix: manually trigger GC in 3.…
pgdr commentedon Oct 13, 2025
@picnixz It could be that it is some hash collision/PGO stuff, but by bisecting, I found that the first commit that makes the following code run in more than just a couple of seconds, is e28477f (from #117120 --- Change the size of the GC increment to about 1% of the total heap size).
That said, the code base today runs the code in around 20 seconds on my computer. Which means that Python went from 4 seconds to 60 seconds down to 20 seconds.
Appendix: bisection log
First bad commit
e28477f is the first bad commit
commit e28477f214276db941e715eebc8cdfb96c1207d9 Author: Mark Shannon <mark@hotpy.org> Date: Fri Mar 22 18:43:25 2024 +0000 GH-117108: Change the size of the GC increment to about 1% of the total heap size. (GH-117120) Include/internal/pycore_gc.h | 3 +- Lib/test/test_gc.py | 35 +++++++++++++++------- .../2024-03-21-12-10-11.gh-issue-117108._6jIrB.rst | 3 ++ Modules/gcmodule.c | 2 +- Python/gc.c | 30 +++++++++---------- Python/gc_free_threading.c | 2 +- 6 files changed, 47 insertions(+), 28 deletions(-) create mode 100644 Misc/NEWS.d/next/Core and Builtins/2024-03-21-12-10-11.gh-issue-117108._6jIrB.rstpicnixz commentedon Oct 13, 2025
cc @markshannon
pgdr commentedon Oct 13, 2025
Perhaps interestingly, we see the exact same behavior if we use
functools.cacheand recursion:This probably just shows that
cacheusesdictto memoize.sergey-miryanov commentedon Oct 14, 2025
It seems that your example abuses the GC.
If you set
gc.set_threshold(2000*2000), for example, you will get a better result.After some digging, I found the following.
BINARY_OPorSTORE_SUBSCRgc.young.count.gc.young.countgrows until it reachesgc.threshold[0]value, at which point the GC will be scheduledJUMP_BACKWARD) the scheduled GC will be run.Where is the problem?
In the
tuple_dealloc:cpython/Objects/tupleobject.c
Lines 215 to 218 in 13e9c2d
There is a condition that checks the freelist and calls
tp_freeunder certain conditions.tp_freefor PyTuple is aPyObject_GC_Delwhich decrements the value ofgc.young.count.For some reason, the condition is false in our case, so we don't call
PyObject_GC_Deland don't decrementgc.young.count.I don't yet know how to fix this. However, I will investigate further.
sergey-miryanov commentedon Oct 14, 2025
Ok, I studied a bit further how freelists work.
So
tuple_deallocworks as expected.What is going on:
Runtime has a set of freelists for tuples (for different sizes - from 1 to 10), size of the each freelist for tuples are 2000 elements.
When we create new tuple, we call
tuple_allocand if freelist has available items - we get one and use it.In our case we create 4 tuples on each iteration. One of them we store as a key in our dict. Other 3 tuples are short-living and
tuple_dealloccalled for them and they returned to freelist. But we took 4 and return 3. This leads to the situation when after some iteration there are no available items in freelist and we create new one withPyObject_GC_NewVarand increfgc.young.count.Created tuple then will be returned to the freelist. And after some repetitions of this situation, we get
gc.young.count == 2000and GC will be scheduled and run.When we run the schedule GC then the incremental version is called. The incremental version has to check local variables/frames and mark live objects and exclude them from the list to process.
So, we run the GC in our cycles may times. Each run will check the local
ddict and all keys and values of it. On each run we have to check more and more keys.This is a problem as I see it. And @pgdr right about bisected commit.
There are two approaches to work around the problem for now - disable the GC before and enable after filling the local dictionary. Or change the value of the first threshold for more appropriate value.
pgdr commentedon Oct 14, 2025
I can confirm that the following code
takes Python 3.14 from 10 seconds to 1.4 seconds by toggling disable.
pgdr commentedon Oct 14, 2025
Apologies for the noise.
With
test.pyas:efimov-mikhail commentedon Oct 14, 2025
It sounds like critical problem with 3.14. IMO, we should fix it ASAP.
cc @nascheme @hugovk
30 remaining items
gh-139951: Tests on tuple GC tracking (#140575)
pythongh-139951: Tests on tuple GC tracking (python#140575)
vstinner commentedon Oct 28, 2025
Tests have been added. Can we close the issue?
If someone wants to work on a GC-specific benchmark, I suggest opening an issue in the pyperformance project. But it seems like @nascheme already wrote such benchmark (even if it doesn't cover this specific issue).
nascheme commentedon Oct 28, 2025
I created an issue in the pyperformance repo for it.
efimov-mikhail commentedon Oct 28, 2025
I think so.
[3.14] gh-139951: Tests on tuple GC tracking (GH-140575) (#140701)
Disable test failinng on upstream CPython
Revert GC changes to "work_to_do" logic.
pythongh-139951: Test on GC collection disabled if threshold is zero (p…
pythonGH-139951: Fix major GC performance regression (pythonGH-140262)
pythongh-139951: Tests on tuple GC tracking (python#140575)
Revert GC changes to "work_to_do" logic.
Revert GC changes to "work_to_do" logic.