Skip to content

Stop the test shards' memory growing with every Simulation under sysmon coverage - #2331

Open
MaxGhenis wants to merge 9 commits into
ci-test-gc-freezefrom
ci-test-memory
Open

MaxGhenis wants to merge 9 commits into
ci-test-gc-freezefrom
ci-test-memory

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2330

Stacked on #2317 (which is stacked on #2269). The diff against ci-test-gc-freeze is this PR's change alone. Test code only.

Cause

Coverage's sys.monitoring core, which #2269 turns on with COVERAGE_CORE=sysmon, keeps every code object it has seen alive, so it can key its records by id(code) (SysMonitor.code_objects). policyengine-core compiles a system's ~1,110 variable files again for each new system, and every Simulation builds one. By the end of a CI shard, each pytest-xdist worker's coverage held 0.8 to 1.46 million code objects, and the shards used up to 1.8 GiB of swap. Details in #2330.

Change

policyengine_uk/tests/conftest.py:

  1. Shared variable code. SourceFileLoader.get_code is wrapped for policyengine-core's per-system variable modules only (names <id>_<hash>_<file>).
    • Each variable file is compiled once per process, and every later system runs that code object.
    • Systems still get their own modules, classes, functions and globals.
    • The wrapper is installed in Cut the CPU the Python tests spend in garbage collection #2317's pytest_configure and removed in its pytest_unconfigure. So it also applies when a bare pytest finds the conftest during collection.
  2. Amortized module release. release_after_new_systems() runs release_dead_variable_modules() once more than eight systems own modules beyond those alive at the last release.
    • It is called as each new system starts adding its variables (a wrapper on TaxBenefitSystem.add_variables_from_file) and after each test.
    • One property test had 165 systems' modules loaded at once. A process that keeps many systems alive (an lru_cache of systems) no longer scans the heap after every build and test.
  3. Freed memory returned. After a test file's fixtures are torn down, glibc's malloc_trim(0) returns free heap memory; elsewhere this does nothing. The microsimulation worker had kept ~4 GiB of RSS after its simulations died, though locally only the 0.74 GiB cached dataset stayed referenced (tracemalloc).

test_latest_data_smoke.py stops keeping its unpinned dataset in _url_dataset_cache after its module. No other test uses that URL, and the cache entry is kept if the pinned dataset is set to the same URL.

The upstream fix for (1) is PolicyEngine/policyengine-core#608. Once it ships, the tests of the wrapper skip with a note to remove it, and the release (2) keeps working, because it no longer depends on the loader.

Measured on CI

Each run built one commit in every configuration at the same time. The 2 s runner sampler is from gc-measure/2317; a per-worker probe logs RSS after every system build and every test. The runner has 15.6 GiB.

Configuration Run Peak used, shards 1 / 2 / 3 Swap pytest wall
Before (sysmon, code compiled per system) 38159894687 15.29 / 15.21 / 14.36 GiB up to 1.84 GiB 859 / 1,224 / 730 s
(1) 38159894687 12.15 / 11.05 / 10.22 GiB 0 677 / 991 / 621 s
(1), an earlier per-build (2), and (3) 38165445604 8.24 / 8.00 / 8.19 GiB 0 776 / 952 / 637 s
The same on 2 shards 38165445604 8.11 / 8.09 GiB 0 1,308 / 1,100 s
This PR's final code (591f90f) 38173323996 8.70 / 7.56 / 8.71 GiB 0 702 / 877 / 628 s
  • Outcomes. Within each run, every configuration had identical test outcomes. The final run had 3,784 passed and 3 skipped. That is every earlier test plus this PR's new tests, which ran on the Linux runners rather than skipping. test_an_exported_frame_loads_back_and_exports_itself, which failed on this base, was deselected in every run, so every figure covers the suite without it. It is fixed on main in 3b8f54b.
  • A/B within run 38165445604.
    • Per-build release without (3): 10.40 / 10.12 / 10.00 GiB, 788 / 902 / 581 s.
    • With (3): 8.24 / 8.00 / 8.19 GiB.
  • ctrace for comparison (no sharing, run 38159894687): 12.43 / 11.47 / 10.87 GiB, 1,162 / 1,412 / 1,007 s wall.
  • Local CPU figure. A UK system build took 0.180 s of CPU instead of 0.289 s. That was measured with PYTHONDONTWRITEBYTECODE=1 and no __pycache__, so it compares against compiling every file for every system. With valid .pyc files the per-system saving is smaller; the CI wall times above are the measured effect.

Invariants (tests)

  • test_variable_code_sharing.py:
    • Two systems have distinct variable classes, modules, formula functions and globals, and the same formula code objects.
    • The shared code for every loaded variable file equals compile() of its current source, and every variable file is covered.
    • Under sysmon coverage, a Simulation adds fewer than 100 code objects to coverage, against more than 1,000 with sharing turned off.
    • Once core shares code itself, these tests skip.
  • test_variable_module_release.py:
    • A test that builds 11 systems runs a release and never has more than (live systems + 9) systems' modules loaded, whatever other modules keep alive. Without the release hook it reached live + 11.
    • A Hypothesis test models builds, deaths and releases with faked owner counts. A scan happens exactly when more than max_new owners are beyond the live ones, and at most max_new remain after.
  • test_malloc_trim.py: it trims after a file's last test and not between tests of one file, and with glibc it finds malloc_trim.
  • Each test fails with its change removed. Checked: the sharing and release hooks, the trim boundary, and an off-by-one in the release trigger.

Review

An independent Opus 5.5 review (~/reviews/uk-ci-test-memory-2026-10-11/review-2331-opus-*.md) went through three rounds:

  1. It requested changes at 7e96e72. The per-system release was order-dependent, scanned per build, and was bypassed by core#608; there were smaller items too.
  2. It approved 4a88e23, which fixed all 11 findings.
  3. It approved 591f90f, which took its nits.
  4. Its approval stood at 4ee6936, which re-cites the final run in comments. 4d472d9 rewords one comment as it asked, citing each run for its own figure. GPT-6.1 Sol attempts died on Codex transport failures.

Follow-ups (not in this PR)

axiom: n/a: test infrastructure

🤖 Generated with Claude Code

MaxGhenis and others added 5 commits October 11, 2026 15:35
Coverage's sys.monitoring core (COVERAGE_CORE=sysmon on CI) keeps every code
object it has seen alive. policyengine-core compiles a system's variable files
again for every new system, so each Simulation left about 2,500 code objects
behind and the test shards grew to the runner's memory. The conftest now
returns the code object compiled the first time for each variable file, so
every system runs the same code with its own modules, classes and functions.

Fixes #2330

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A property test on CI had 165 systems' variable modules loaded at once,
because dead systems' modules were released only after each test, and
Hypothesis builds hundreds of systems while it shrinks a failing example.
The loader hook now also runs release_dead_variable_modules() when a new
system starts loading its variables, so at most max_owners dead systems'
modules are held. Also record the CI before/after numbers in the comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On CI the worker that ran the microsimulation tests kept about 4 GiB of
RSS after their simulations were gone, though locally only the 0.74 GiB
cached dataset stayed referenced. The conftest now calls glibc's
malloc_trim(0) once a file's fixtures are torn down, and the latest-data
smoke test removes its unpinned dataset from the simulation cache when its
module ends, since no other test uses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pytest_sessionstart never reaches a conftest that pytest finds only while
collecting, as with a bare `pytest` from the repository root, so the hook
was not installed there and the sharing and release tests failed.
pytest_configure is also called for conftests registered later. The loader
is restored in the existing pytest_unconfigure (a second definition would
replace it). Also read coverage's tracers with getattr in the test helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 2 commits October 11, 2026 16:32
Opus review of 7e96e72:
- The release ran from the SourceFileLoader hook, which policyengine-core#608
  bypasses, so it would have stopped without notice. It now runs from
  TaxBenefitSystem.add_variables_from_file as each new system starts adding
  its variables.
- With more than eight systems alive (an lru_cache of systems in a test
  module), every new system and every test scanned the heap and released
  nothing; a worker's later tests took 432 s instead of 219 s. The release
  now scans only once eight more systems own modules than were alive at the
  last scan, after tests as well as builds.
- The release test assumed at most eight live systems and failed in a serial
  run; it now measures the live systems first.
- Sharing tests skip, with a note, once policyengine-core shares the code;
  the note names the right function.
- The malloc_trim test checks for glibc rather than Linux, malloc_trim gets
  its C signature, the loader restore leaves no copy on the subclass, the
  latest-data fixture keeps the cache when the latest URL is the pinned one,
  and comments say "about the same time" and "can build many more".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opus delta review of 4a88e23 (approved) left three nits: the comment
was off by one (a scan needs more than max_new owners beyond the live
ones); the release test could pass if builds reused dead systems' ids, so
it now also asserts a release ran; and nothing pinned the amortization, so
a Hypothesis test models builds, deaths and releases with faked owner
counts and checks that a scan happens exactly when more than max_new
owners are beyond the live ones and that at most max_new are left after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 11, 2026
…tests

Not for merging.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 2 commits October 11, 2026 17:41
Run 38173323996, at 591f90f with all of PR #2331's changes, peaked at
7.6 to 8.7 GiB; the release comment no longer attributes run 38165445604's
figure to the amortized trigger, which that run did not use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 11, 2026 21:44
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Hand-off to the UK hub (session "Cut peak memory of pe-uk CI test shards").

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

UK hub (2026-10-12 11:05Z): this PR's head 4d472d9c5 is merged unchanged into rollup B, #2380. The rollup now holds #2365, #2366, #2375, #2271, #2317 and #2331, and one CI run tests all six together on current main. Merging #2380 with a merge commit marks this PR merged. The hub cancelled this PR's own queued runs, which the rollup replaces. Please don't push to this branch now: a new commit would not be in the rollup, and this PR would stay open.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant