Repository navigation
Cut the CPU the Python tests spend in garbage collection - #2317
Merged
Merged
Conversation
Each process that runs tests raises the first-generation GC threshold from 2,000 to 10,000 at start-up and calls gc.freeze() once the tests are collected, so collections stop walking the ~430,000 objects present after importing policyengine_uk. release_dead_variable_modules now also counts the frozen tax-benefit systems as live, since gc.get_objects() leaves frozen objects out; without that it would drop the import-time system's variable modules. Fixes #2316 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 11, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… exit Review findings on #2317: - Two test modules built a Simulation at import (council tax uprating's region-to-country map, the childcare test's minimum care hours). Their systems died during collection, so their variable modules were frozen, then released and never freed (about 19,000 objects per worker). Read the hours from the package's parameters and build the map on first use. - conftest.py records any variable-module owner whose system died before the freeze, and test_no_system_dies_before_the_freeze fails on one. - Set only the first-generation threshold and keep the interpreter's other thresholds (CPython 3.14.4 defaults to (2000, 10, 0)). - Undo the freeze and threshold at unconfigure, for in-process runs. - Correct the comment: about 1.6 million objects are frozen per worker, and on the shard whose runner memory isn't full the largest worker's peak rose from 5.3 to 5.7 GiB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 of 6 tasks
MaxGhenis
marked this pull request as ready for review
October 12, 2026 10:17
From the GPT-6.1 Sol review of a8ff60f: frozen objects outside reference cycles are still freed by reference counting, and gc.unfreeze() also unfreezes anything frozen before the session. Comments only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collaborator
Author
|
Hand-off to the UK hub (session happy-turing-d05840 / f9b280dd).
|
Collaborator
Author
|
UK hub (2026-10-12 11:05Z): this PR's head |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #2316
Follows #2269 (test shards), which landed in #2364; this branch has main merged in, so the diff is this PR alone. #2331 (test-shard memory) is stacked on this PR, and the two should land together (see "Memory").
Change
In
policyengine_uk/tests/conftest.py, each process that runs tests (each pytest-xdist worker):pytest_configure), instead of CPython 3.13's 2,000; the other thresholds keep the interpreter's defaults;pytest_collection_finish), runsgc.collect()and thengc.freeze(). That freezes about 1.64–1.66 million objects per worker (gc.get_freeze_count()in every measured worker), of which about 430,000 come from importingpolicyengine_uk; the rest are test modules, collected items and the tax-benefit systems built at import. The collector never scans them again; frozen objects in reference cycles that later become garbage are not freed until they are unfrozen (objects outside cycles still go with their last reference). Objects the tests create are collected as before;pytest_unconfigure, so an in-processpytest.main()(an IDE, a REPL) leaves an interpreter that had nothing frozen as it found it (gc.unfreeze()would also unfreeze anything frozen before the session).A tax-benefit system and its variable modules form reference cycles, so a system that dies after the freeze is never freed. Two test modules built a
Simulationat import (test_council_tax_uprating.py's region-to-country map andtest_uc_childcare_work_condition_properties.py's minimum care hours); their systems died during collection, so their variable modules were frozen, then released and never freed: about 19,000 objects (11+ MB) per worker, found in review. Both now avoid it (the map is built on first use; the hours come frompolicyengine_uk.parameters).conftest.pyrecords any variable-module owner added during the session whose system died before the freeze (owners already loaded when the session started, from an earlier in-process run, are exempt), and the newtest_no_system_dies_before_the_freezefails on one (red against the old modules: "2 tax-benefit system(s) were built and dropped while the tests were collected").release_dead_variable_modulesnow also counts the frozen tax-benefit systems as live.gc.get_objects()leaves frozen objects out (checked on Python 3.13.15 and 3.14.7). Without this change, the release would takepolicyengine_uk.tax_benefit_system.systemfor a dead system and drop its 1,110 variable modules fromsys.modules. The newtest_frozen_systems_keep_their_modulesfails without that line and passes with it.Measured on CI
Each measurement run adds a workflow to the PR on a branch that will not be merged (
gc-measure/2317*; runs 1–3 oncffb3722a, the first commit; run 4 on50ebd8877, after the review fixes, which differs from the head only by the merge of main). Every setting runs the Python tests as three shards in one workflow run, on separate runners, from one commit, the way #2269'sTest (Python K/3)jobs ran them:-n 4 --dist worksteal, coverage with sysmon, Python 3.13 onubuntu-24.04-arm. Main has since moved to four shards for memory (827b277,.github/workflows/test-suite.yaml); the comparisons below are all on three.getrusage), its time inside the cycle collector (gc.callbacks, wall-clockperf_counter, so the "in the collector" column is wall time while "Worker CPU" is CPU time) and its peak RSS. Its overhead is negligible next to the differences measured (the earlier review benchmarked the callbacks).test_an_exported_frame_loads_back_and_exports_itself, on which Hypothesis spends minutes shrinking;test_frozen_systems_keep_their_modules, which asserts the new settings and so fails under the others.Totals over the three shards, in minutes:
"4 × pytest wall" is the worker-minutes measure #2269 used (four workers, each busy until its shard ends). "Job time" is the three shard jobs' runner time.
50ebd8877lost shard 2's runner at 90% ("The runner has received a shutdown signal"; memory exhaustion is inferred from the swap figures, not logged on that run), and with Python's defaults the same shards came as close (2.4 GiB of swap). Stop the test shards' memory growing with every Simulation under sysmon coverage #2331 reports that coverage's sysmon core keeps every compiled code object alive; with its fix on top of this PR the runners peak at about 8.1 GiB with no swap (below), which is why the two should land together.cffb3722a: Python shard jobs took 14.7 / 22.8 / 13.2 minutes, red only on main's then-failing dataframe test. Run 38168650620 at50ebd8877: shard 2's runner shut down at 90% (memory, as above); no test failed.Shard count
The task asked whether the compute saved would allow two shards instead of three. Not with this PR alone; yes once #2331 lands. Main has meanwhile moved from three shards to four for memory (827b277).
mem-measure/2331-run2b; peaks, walls, swap and outcomes checked against its artifacts): two shards peaked at 8.11 / 8.09 GiB, pytest wall 1,308 / 1,100 s; three shards 8.24 / 8.00 / 8.19 GiB, 776 / 952 / 637 s; no swap, and no test failed in any job. Two shards then cost the same worker time (4 × wall: 161 against 158 minutes), save one runner pickup and setup (under a minute each here), and make the slowest shard 22 minutes instead of 16.test-suite.yaml, which W2 owns; it is left as a follow-up for after Stop the test shards' memory growing with every Simulation under sysmon coverage #2331 lands.Invariant
Same tests, same outcomes: the change alters when memory is reclaimed, not which tests run or what they compute.
Testjob's shard check confirms every collected test ran in exactly one shard. In this PR's run, 3,777 were collected, and the shards ran 1,206 + 1,280 + 1,291.test_frozen_systems_keep_their_modules: the freeze and threshold are in effect, and a frozen live system keeps its variable modules (red without the_FROZEN_SYSTEMSlookup).test_no_system_dies_before_the_freeze: nothing frozen is a dead system's modules, which would leak (red against the two old test modules).test_systems_built_after_the_freeze_are_still_collected: a system built after the freeze is freed bygc.collect()once dropped. It documents the property; it would also pass without a freeze.test_dead_systems_modules_are_released_and_live_ones_kept(existing) still passes.Not property-based: the invariant is over the whole suite, so the differential run above is the test for it.
Tests run locally
Python 3.13.9:
pytest policyengine_uk/tests/test_council_tax_uprating.py policyengine_uk/tests/test_uc_childcare_work_condition_properties.py policyengine_uk/tests/code_health/test_ci_test_shards.py -n 4, after merging main: 62 passed.test_variable_module_release.py, with full collection and-n 4, after merging main: 5 passed.pytest policyengine_uk/tests -k "test_no_system_dies_before_the_freeze or test_frozen_systems_keep"(full collection, with and without-n 2): 2 passed.pytest.main()twice in one process: both pass, and the threshold and freeze count are back to(2000, 10, 10)and 0 after each.ruff formatandruff checkare clean on the changed files. The full suite runs in CI.Review
057132c1e: REQUEST_CHANGES (~/reviews/uk-hub/reviews/uk2316-gc/review-opus-out.md); every finding addressed in50ebd8877.a8ff60fa0: APPROVE (~/reviews/uk-hub/reviews/uk2316-gc/review2-out.md), with documentation nits. The comment nits are in37cadb471(comments and docstrings only; the UK hub checked both files are AST-identical toa8ff60fa0ignoring them), and the rest are in this description.axiom: n/a: test infrastructure
🤖 Generated with Claude Code