Repository navigation
Conversation
|
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
All runs passed every case.
Scripts, logs and raw results: |
91744d3 to
2026977
Compare
|
Rebased onto |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
…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>
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>
5b41542 to
ebef3bf
Compare
|
I've dropped the commits I added to this branch and restored Max's description.
Review kept finding edge cases in how Core's parameter caches are filled, invalidated, copied and shared, mostly in behaviour that predates this PR. #621 proposes rules for those caches, and this PR's changes to parameter caching should wait for them. |
|
Picking this up for the US hub. I'll post a hand-off comment here when it's ready. Generated by Claude Code |
…build paths Master's #643 pauses automatic collection around a reform-system build and forces a full collection only while the cache holds fewer than three systems. This branch replaced the unbounded cache with a reform-free slot and an LRU of reform systems, so the merge wraps both the reform-free and the reformed build in the pause, leaves cache hits outside it, and counts each baseline's reform-free system plus its reform systems across every baseline, after the call's bound trims and before the new system goes in. #643's tests assumed an unbounded dict cache; they now pass a large bound where they need the cache to grow, the Hypothesis test checks a reference LRU, and new tests cover reform-free misses, rebuilding an evicted system, a disabled cache, a count of 2 against 3, and restoring collection after a KeyboardInterrupt and after a gc.collect() that raises. The sequence invariant (at most three forced collections) is qualified: it holds while the cache only grows, and a reform cache of 0 or 1 keeps it below three for good. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwQq6FhvrBWcuxPZ2hrc8P
policyengine-uk's test conftest drops a file's dotted-input systems after its last case by iterating each baseline's cache entry and deleting the keys with a reform_key, as it could with the old dict. The bounded cache's entry was not iterable, so every policyengine-uk YAML run ended in a teardown TypeError and exited 1 after all cases passed. Iterating, indexing, `in` and `del` now act on the reform LRU with the same keys, so that hook keeps working until it is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwQq6FhvrBWcuxPZ2hrc8P
|
Picking this up for the US hub. I'll post a hand-off comment here when it's ready. Generated by Claude Code |
|
Picking this up for the US hub. I'll post a hand-off comment here when it's ready. Generated by Claude Code |
Master gained only release-workflow and version changes since the last merge; this keeps the branch current so CI runs against it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since the master merge brought in the build-time collection pause, a cache miss below three cached systems ends with a full gc.collect(). The property test's 300 examples made about 1,450 of them, each walking the whole pytest heap; under CI's coverage tracing the file took 19 to 26 minutes and pushed three Test jobs past their 45-minute timeout. Collection never changes what the cache returns and test_gc_pause.py checks it with a recording spy, so the property now stubs it out: 72 s to under 1 s locally. Also qualify the comment at _FULL_COLLECTION_CACHE_LIMIT: the cache stays at three only once it holds the reform-free system and two reform systems, and eviction or a hook can keep it below that. Restore master's uv.lock, which this branch had rewritten to an older lock revision and a stale version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Ready for hub review The branch was fast-forwarded from 1. Review findingsThe PR has no formal reviews and no inline comments. Its findings are in comments:
2. Master merge in
|
| File | Core | Builds | Peak RSS (3 rounds) | Wall (3 rounds) |
|---|---|---|---|---|
UK baseline/gov/hmrc/private_school_vat.yaml, 15 cases, all passed |
master | 10 | 1085, 1085, 1084 MB | 14.7, 13.3, 11.8 s |
| head | 12 at size 2 | 785, 785, 785 MB | 13.1, 12.9, 13.1 s | |
US baseline/gov/states/az/tax/income/credits/az_property_tax_credit_schedules.yaml, 142 cases, all passed, no reforms |
master | 1 | 2851 ×3 MB | 102.7, 99.7, 103.2 s |
| head | 1 | 2533 ×3 MB | 84.4, 86.6, 85.1 s |
The US contrib files didn't fit under 4 GiB at master.
contrib/states/hi/dependent_exemption_reform_test.yamlpassed 4 GiB at master within 55 s, while its first case ran. The log shows onlyMEASURE rc=-9 wall=54.7s peak_rss=4105MB killed_over_4096MB=True.- The one-reform
contrib/aca/ptc_700_fpl_cliff.yamlalso passed 4 GiB, at master and head, in all three rounds (killed at about 61-64 s). - A US baseline plus its reform-free system already peaks at 2.15 GB. No US file that builds a reform system fits, so I used the reform-free baseline file above.
Rebuild count at reform_cache_size 2 on a US reform file. I ran the HI file once per configuration, with a 12 GB ceiling, outside the 4 GiB protocol. All passed 17 cases.
- master: 3 builds, 7,109 MB, 205 s
- head at size 2: 8 builds (5 rebuilds), 8,369 MB, 276 s
- head with
--reform-cache-size 3: 3 builds, 7,101 MB, 194 s
On that file the default is worse than master. The file alternates among 3 combinations, one more than the cache holds. Each miss builds a third system while two are cached. The evicted one is dropped only after the paused build, and its cycles wait for a later collection. The PR body now says the bound is about reform_cache_size + 2 reform systems at peak, not reform_cache_size.
Mutants
Mutants were applied by exact string replacement to test_runner.py, one at a time. Each ran against the three test files, one process per file, and was restored with git checkout.
| Mutant | Result |
|---|---|
| Keep the simulation on the item after teardown | killed: 8 in test_runner_memory.py (e.g. test_finished_cases_release_their_simulations) |
| Drop the system's back-reference reset | killed: 11 in test_runner_memory.py (e.g. test_cases_whose_simulation_fails_to_build_release_it[*]) |
Evict nothing (both popitem loops) |
killed: 4 in test_gc_pause.py, test_cache_matches_reference_lru, and 5 in test_runner_memory.py |
| Pause collection on a cache hit (both paths) | killed: 6 in test_gc_pause.py (e.g. test_a_cache_hit_leaves_collection_alone) |
finally replaced by success restoration plus except Exception |
killed: 6 KeyboardInterrupt cases in test_gc_pause.py, e.g. test_the_pause_restores_the_callers_state_after_a_keyboard_interrupt[True]. It survives test_runner_memory.py and test_reform_cache_property.py. |
CI
Pull request run 41731108677 at cb9abe89 concluded success. All 19 jobs succeeded and none was cancelled. The 8 Test jobs took 15-27 min (ubuntu 3.14 15, ubuntu 3.12 18, ubuntu 3.13 19, ubuntu 3.11 20, windows 3.14 18, windows 3.13 23, windows 3.12 27, windows 3.11 27), against the 45-minute timeout. The 8 country-package smoke jobs took 1-3 min.
Why run 41401698001 had cancelled jobs. The branch made the Test jobs slow; nothing hung.
pr.yamlsetstimeout-minutes: 45on Test. At17fc0fd1cethe five jobs that finished took 33-44 min. The same jobs on master-based PRs took 7-18 min (runs 41548423680, 41636046932, 41706941813 and 41714952193).- The logs of the cancelled ubuntu 3.11 job and the passing ubuntu 3.14 job put 1,578 s and 1,143 s in
test_reform_cache_property.py. The cancelled job was intest_runner_memory.py's last six tests when the timeout fired, at 86% of the suite. - The Pause automatic garbage collection while the YAML runner builds a reform system #643 merge made each miss in that property test run a real full
gc.collect(), because the cache stays below 3 systems. That came to 1,453 collections over the whole pytest heap, undercoverage --branch. - Fix:
tests/core/tools/test_runner/test_reform_cache_property.py:67stubsgc.collectper example (72 s to 0.6 s locally). Collection is checked bytest_gc_pause.py's spy. - Still heavier than master:
test_runner_memory.py(about 4 min on CI) andtest_parameter_tracing_cache_property.py(about 2.5-3 min). Both collect on purpose to count live objects.
Judgment calls
- No code change for anth-volk's Define how Core's parameter caches are owned, invalidated, copied and shared #621 finding. It's about landing order, not a defect.
- Step 2's "after any required trimming" read as the call's own bound, not making room for the new entry. Evicting before the build would cut the count to 2 with the default cache, so every miss would force a collection.
- I didn't redesign the eviction or collection order to fix the HI peak. That change belongs with Pause automatic garbage collection while the YAML runner builds a reform system #643's design, so I've proposed it as a follow-up.
uv.lockrestored to master's copy, rather than kept at the branch's older lock revision.- The PR body can't keep the exact "🤖 Generated with [Claude Code]" line: the GitHub proxy replaces it with its own Claude Code attribution footer on every PATCH.
Proposed follow-up issue
Collect an evicted reform system before building its replacement in the YAML runner
When a miss evicts, the evicted system is dropped only after the paused build, so a 3-combination policyengine-us file peaks at 8.4 GB with reform_cache_size 2 against 7.1 GB on master. Evict first and collect before the build when something was evicted, then re-measure #643's collection-CPU model.
Generated by Claude Code
Draft. Head
cb9abe89b634deaf555df0e7b4f1001faa25f3b2, withmaster(c86af926) merged in. Measurements and targeted tests below are at this head; CI is the evidence for the full suite. The PR stays a draft while #621 (rules for Core's parameter caches) is open; see "Open" below.Problem
A
policyengine-core test <~1,500 policyengine-us baseline YAML files> -c policyengine_usprocess grew without bound (two runs reached 118 GB and 111 GB on a 128 GB machine on 2026-10-02). Two causes are intools/test_runner.py:YamlItemkept itssimulationandtax_benefit_systemafter its case ran, so every simulation of the run stayed alive.Simulation.__init__also setstax_benefit_system.simulation = self, so a cached system kept the last simulation built on it._tax_benefit_system_cachekept every system it built, one per distinct combination ofreforms,extensionsand dotted parameter inputs, and never evicted. Each is a full clone of the baseline.A third cause, a copy of the whole parameter tree per instant, is #570's subject; the two PRs are independent.
Change
YamlItem.teardowndrops the case's simulation and system, restores the system'ssimulationback-reference to what it was before the case (also when building the simulation failed), and unmarks a parameter tree that outside code marked with this case's tracer.reform_cache_sizereform systems (default 2;policyengine-core test --reform-cache-size Noroptions["reform_cache_size"]; 0 caches none). Each call's bound first trims what earlier calls cached. A baseline's entry still iterates, indexes and deletes as a mapping of reform systems, so test hooks that walk it keep working.repr(0.2 and "0.2" differ), and a class reform with noreform_keyby the class itself.ParameterNode._get_at_instantand inTaxBenefitSystem.get_parameters_at_instant, so a cached wrapper no longer keeps the first traced simulation's tracer alive.finally, so it survives anyBaseException, includingKeyboardInterruptand agc.collect()that raises.Invariants
simulationback-reference is what it was before the case, including after a failed build. No simulation or tracer created by a probed run stays alive, and the number of live systems does not grow with the number of cases (test_runner_memory.py).test_traced_memory_stays_flat_over_hundreds_of_cases).test_reform_cache_property.py; alsotest_gc_pause.py's property).reform_cache_sizeof 0 or 1, or a hook that deletes entries. Then every miss forces one.Measurements at this head
Each run is one
policyengine-core test <file> -c <package>process: CPython 3.14 (US) and 3.13 (UK), Linux, 4 vCPU, policyengine-us6145ad45, policyengine-uk9b456bd. Peak RSS is polled from/procand checked againstru_maxrss. Builds count the distinct system objects_get_tax_benefit_systemreturned. Master and head runs were interleaved, three rounds each, and every run passed every case.tests/policy/baseline/gov/hmrc/private_school_vat.yamlc86af926baseline/gov/states/az/tax/income/credits/az_property_tax_credit_schedules.yaml(no reforms)At
reform_cache_size2 the UK file rebuilds 2 systems that master kept, and still peaks 300 MB lower.No policyengine-us file that builds a reform system fits under 4 GiB. The 4 GiB limit was a constraint of this measurement session.
contrib/aca/ptc_700_fpl_cliff.yamlbuilds one reform system. Master and this PR both passed 4 GiB in about 61–64 s, all three rounds.contrib/states/hi/dependent_exemption_reform_test.yamlbuilds 3 reform combinations over 17 cases. At master it passed 4 GiB within 55 s. One run each, outside that limit:reform_cache_size2 (default)--reform-cache-size 3On this file, the default of 2 costs 5 rebuilds and 1.26 GB more peak than master. The file alternates between 3 combinations, one more than the cache holds. A miss builds a third reform system while two are cached. The evicted system is dropped only after that paused build. Its reference cycles then wait for a later collection, so up to four reform systems can be alive at once. With size 3, the PR matches master. The bound pays off on runs with more combinations than the cache holds plus one, such as the multi-file batches measured on October 3 (linked below), where master's cache grows without limit. The peak bound is about
reform_cache_size+ 2 reform systems, notreform_cache_size.CI
Run 41401698001 at
17fc0fd1cecancelled three Test jobs (windows 3.12, ubuntu 3.11, windows 3.11) atpr.yaml's 45-minutetimeout-minutes. They didn't hang: the other five took 33–44 min, against 7–18 min for the same jobs on master-based PRs (runs 41548423680, 41636046932, 41706941813, 41714952193). The jobs' logs put 19–26 min of that intest_reform_cache_property.py. After the #643 merge, each of its cache misses below three cached systems ran a real fullgc.collect(). That was 1,453 collections over the pytest heap, made dearer bycoverage --branch. The property now replacesgc.collectwith a no-op: 72 s to 0.6 s locally.test_gc_pause.pychecks collection with a recording spy.Tests run at this head
One file per process,
uv run pytest -q -p no:cacheprovider <file>:tests/core/tools/test_runner/:test_gc_pause.py44 passed,test_reform_cache_property.py1,test_runner_memory.py27,test_yaml_runner.py14test_parameter_tracing_cache.py5,test_parameter_tracing_cache_property.py1,test_tracing_parameter_isolation.py49,test_tax_benefit_system_cache_identity.py2,test_yaml.py15ruff format --check .(376 files) andruff check .pass.Mutants of
test_runner.py, each in its own process and restored after, all killed:test_runner_memory.py.test_runner_memory.py.test_gc_pause.py, the property intest_reform_cache_property.py, 5 intest_runner_memory.py.test_gc_pause.py.except Exceptioninstead offinally: 6KeyboardInterruptcases intest_gc_pause.py.Not run: the full suite, the country template YAML suite, any full country suite, and microsimulations.
Open
Downstream impact
Earlier measurements on two policyengine-us CI batches (October 3, core
760a001a, alone and with #570) are of an earlier snapshot, not this head. No country-policy or partner baseline files change. Whichever of #569 and #570 lands second must rerun the overlapping parameter-cache and runner-memory tests.https://claude.ai/code/session_01LwQq6JNXsLbdg36pd2zkMe
Generated by Claude Code