Skip to content

Stop the YAML test runner keeping every case's simulation and reform system - #569

Draft
MaxGhenis wants to merge 11 commits into
masterfrom
fix-test-runner-memory
Draft

MaxGhenis wants to merge 11 commits into
masterfrom
fix-test-runner-memory

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Draft: the October 7 CI repair is at 91744d3e6c88b1a56f7e81f1001f8cdeba3c9af8. Runtime validation at this head remains pending. Existing downstream measurements were completed on earlier snapshots, as linked below.

Problem

On 2026-10-02 two policyengine-core test <~1,500 policyengine-us baseline YAML files> -c policyengine_us processes reached 118 GB and 111 GB on a 128 GB Mac and had to be killed. Two of the causes are in tools/test_runner.py:

  1. pytest keeps every collected item until the session ends, and each YamlItem kept simulation and tax_benefit_system after its case ran. Every simulation of the run stayed alive.
  2. _tax_benefit_system_cache kept every system it built, one per distinct reforms / extensions / dotted-parameter-input combination, and never evicted. Each is a full clone of the baseline. On policyengine-us one measured 1.16 to 1.20 GB and 43 to 47 s to build.

Simulation.__init__ also sets tax_benefit_system.simulation = self, so a cached system kept the last simulation built on it.

The third cause, a copy of the whole parameter tree per instant, is in #570 (lazy ParameterNodeAtInstant); the two PRs are independent.

Change

  • YamlItem.teardown drops the case's simulation, its system, and the system's back-reference to that simulation.
  • The cache keeps the reform-free system for the baseline's lifetime, plus an LRU of reform_cache_size reform systems (default 2). It is set with policyengine-core test --reform-cache-size N or options["reform_cache_size"]; 0 caches none.

Replaying the cache over policyengine-us's whole tests/policy tree in run order (31,393 cases, 433 distinct reform combinations) gives 434 builds with no bound and 504 at size 2 (453 at 4). Two keeps the per-process peak at about two reform systems, and costs about 16% more builds.

Invariants

  • After a case, the runner item drops its simulation/system references and the system's simulation backlink returns to its pre-case value, including failed construction. With external exception owners excluded, no simulation created by the run remains alive; the live-system count does not grow with case count (test_runner_memory.py).
  • Traced memory after 300 cases is within 2 MB of memory after the first 100: 0.5 MB with this change, against 4.0 MB (plain cases) and 18.4 MB (every third case a reform) on master.
  • Differential test against a reference LRU (test_reform_cache_property.py, Hypothesis): for any request sequence and any size 0 to 3, the cache holds at most size reform systems, returns a cached object exactly when the reference LRU would, always returns a system built for exactly the requested reforms and extensions, and keeps one reform-free system that is never the baseline itself.
  • Outcomes are unchanged: caching only decides when a system is rebuilt.

Existing test targets

The following targets were already listed in this PR; they are not claimed as executed at the current head.

  • uv run pytest tests/core/tools/test_runner tests/core/test_tax_benefit_system_cache_identity.py tests/core/test_yaml.py
  • The country template YAML suite with and without --reform-cache-size 1.

October 7 CI repair

Merged canonical master at 5d68130be487f0b87d47fc75ef300f36ad46f7ca with a history-preserving merge and no conflicts.

  • The deepcopy regression had expected a cached tracing wrapper. It now checks the intended plain-node cache, a fresh tracing wrapper for each read, and independent copied nodes/tracers. The parameter-cache implementations and distinct-date tracer/result regressions are unchanged.
  • The eight failed-build simulations in CI were retained by pytest-rerunfailures 16.7, which stores failed ExceptionInfo objects on collected items even with no reruns configured. The nested memory-test session disables that dev plugin while the outer session retains its rerun behavior. The probe explicitly retains collected items; failure tests cover both builder paths, an unknown variable, and absent/existing prior simulation backlinks. A successful-build case checks the same ownership invariant.
  • This repair changes regression tests and the changelog wording. The product runner's plugin behavior remains unchanged: installing pytest-rerunfailures can still retain failed tracebacks. The test isolation is not a production fix for that third-party behavior.

Validation at the repair head

  • Passed: merged and changed Python files formatted before their commits; ruff format --check . (348 files), ruff check ., changed-source AST parsing, and git diff --check.
  • Targeted pytest attempt: ~/reviews/us-hub/scripts/heavy_run.sh core569-targeted env UV_CACHE_DIR=/private/tmp/core-569-build-artifacts/uv-cache uv run --no-sync pytest tests/core/test_tracing_parameter_isolation.py tests/core/tools/test_runner/test_runner_memory.py tests/core/test_parameter_tracing_cache.py tests/core/test_parameter_tracing_cache_property.py tests/core/tools/test_runner/test_reform_cache_property.py -q -n 2 -p no:cacheprovider exited 75 before pytest ran. The sandbox denies the wrapper's ps call, preventing its required nice-level check. No bypass was attempted.
  • No full suite, country suite, documentation build, microsimulation, or fresh memory benchmark ran in this repair. The bounded assignment permits targeted files only.
  • Independent source review approved exact head 91744d3e6c88b1a56f7e81f1001f8cdeba3c9af8 with no unresolved P1/P2 findings in the repair delta. Runtime validation and current green CI remain required; this draft is not marked ready.

Downstream impact

Completed measurements were posted October 3 for core 760a001a, and its combination with #570 4e6bc315, using PE-US 615a0f2d. Those earlier results are not measurements of this repair head. Fresh measurement is pending for 91744d3e6c88b1a56f7e81f1001f8cdeba3c9af8 and its eventual combination with #570.

No country-policy or partner baseline files were changed in this repair; no new partner-impact gate or methodology decision is introduced. Whichever of #569 and #570 lands second must rerun the overlapping parameter-cache and runner-memory tests. Preserve #570's recorded #567 ordering constraint.

🤖 Generated with Claude Code

MaxGhenis and others added 2 commits October 2, 2026 08:35
…ache

pytest keeps every collected item until the session ends, and each YamlItem
kept its simulation and the system it ran on. The runner also cached every
reform system it built, a full clone of the baseline each, forever. A
policyengine-us run of ~1,500 baseline files in one process reached 118 GB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI's full suite failed test_finished_cases_release_their_simulations with one
live Simulation left by an earlier test module in the same process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… by class

Review findings on 8607c73:
- the bound was enforced only after inserting a new system, so a later call
  (or run on the same baseline) asking for a smaller cache, or 0, kept what a
  larger bound had cached: every call now trims to its own bound first;
- class reforms passed without a reform_key all keyed as "" and shared one
  cached system (also on master): they now key by the class; the runner's
  inline reform classes still key by their reform_key;
- reforms key as a tuple rather than a ':'-joined string;
- --reform-cache-size rejects a negative value when it is parsed, rather
  than failing every case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Measurements of this PR, alone and combined with #570, on the two policyengine-us CI batches that have been dying on 16 GB runners since 3.32.12 (PolicyEngine/policyengine-us#9757). Each row is one policyengine-core test <paths> -c policyengine_us process on policyengine-us main 615a0f2d. Setup: CPython 3.14.7 on macOS arm64, CI's itemization setting, core imported from a snapshot of each commit. Peak memory is the process's peak physical footprint (/usr/bin/time -l). CPU is user plus system time; the host was shared and loaded, so wall time is not comparable.

Core NY/OH/OK batch (1,328 cases): peak CPU USDA batch (1,373 cases): peak CPU
3.32.11 (7950c01) 16,282 MB 213 s 17,076 MB 272 s
3.32.12 (b78b0ba) 18,156 MB 302 s 18,935 MB 321 s
#578 on 3.32.12 16,287 MB 243 s 17,055 MB 260 s
#569 (760a001) 9,636 MB 236 s 13,912 MB 237 s
#569 + #570 (760a001 merged with 4e6bc31) 7,033 MB 149 s 6,399 MB 137 s

All runs passed every case.

Scripts, logs and raw results: ~/reviews/ci-shutdown-core-3-32-12-2026-10-02/ on Max's machine (scripts/measure.sh, results/summary.tsv).

MaxGhenis and others added 7 commits October 3, 2026 08:03
…ues by type

Round-2 review of 760a001:
- a case whose simulation failed to build left it registered on the cached
  system: teardown now restores the system's simulation to what it was before
  the case, whether or not the build finished;
- traced cases kept one tracer (and its results) per date: ParameterNode cached
  a tracer-bound wrapper per instant. It now caches the plain node and wraps
  it on each traced read, which also traces nodes cached before tracing
  started; teardown resets the cached system's trace fields when they hold the
  case's tracer;
- inline parameter values key by repr, so 0.2 and "0.2" no longer share a
  system.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TaxBenefitSystem.get_parameters_at_instant cached whatever the parameter
tree returned. While the tree is marked traced that is a tracing wrapper
holding the reading simulation's tracer, so the system kept one tracer, and
the results it recorded, per date; served a node cached before tracing
started untraced; and recorded later reads, after tracing stopped or under a
later tracer, in the first one.

Since #574 core's simulations trace through a per-call view and never mark
the shared tree, so the round-3 scenario (traced YAML cases at distinct
dates whose formula calls get_parameters_at_instant) no longer leaks; its
test fails on 3c7d199 and passes on the merge. Code outside core still
marks the tree (policyengine-us's isolate_parameter_tracing sets trace,
tracer and branch_name on the root), and then the system cache kept 16
tracers after 16 traced cases at distinct dates.

The system cache now caches the plain node and, while the tree is traced,
wraps it for that read only, as ParameterNode._get_at_instant does. Tests:
example tests across 16 distinct dates, a Hypothesis differential property
of the system's path against the tree's, and two YAML runs at 16 distinct
dates with weak references to tracers and recorded results. The docs now say
each system still caches its parameters at every distinct instant read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hypothesis's too_slow health check times input generation by the wall
clock. Round 3 saw test_cache_matches_reference_lru fail it under host load
(53 s of generation) and then pass in isolation; it checks nothing the
property is about.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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