Repository navigation
Conversation
…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>
Building every descendant in the constructor made each instant a system is asked about cost a copy of the whole parameter tree, kept for the system's lifetime. For policyengine-us that is about 34,000 node objects, 16-33 MB and several seconds per instant, per tax-benefit system. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-coherent Review of 404a3a3 found five regressions against the up-front build: - concurrent first reads could raise KeyError/AttributeError or resolve a child twice: first resolution now runs under a module RLock; - children named like the new state or helpers (_node, _resolved, _resolve, ...) read as internal objects: state is name-mangled, helpers are module functions, and children named like the node's own attributes resolve at construction, as before; - a shallow copy and its original could resolve a scale twice: both now read one grow-only resolved map; - a child with no value at the instant was re-resolved on every read: its absence is cached; - deepcopy/pickle of a traced tree's raw node recursed through TracingParameterNodeAtInstant.__getattr__, which now raises AttributeError for its own unset fields. A node also drops its parameter node once every child has been read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ases Round-2 review of a90b524 found reads that still differed from the up-front build. A node now keeps all of its own state in one _LazyChildren object under a single reserved name (a parameter of that name is rejected): - which children a node has, and their order, are fixed at construction (a child deleted from the tree later keeps its place); - completion counts only those children, so add_child extras can no longer send the first read into recursive completion; - resolution results and add_child entries are kept apart: items are shared by shallow copies, attributes belong to each copy, as before; - a copy installs what it reads as attributes (no lock on warm reads); - children named like attributes of the node's runtime type (dunders and subclass methods included) resolve at construction; a subclass overriding add_child gets the up-front build through it; - a reentrant read keeps the first recorded child; - nodes pickled by the up-front build load (__setstate__). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lazy state object has __slots__ and no __getstate__, which pickle protocols 0 and 1 refuse. Master's #584 tests pickle a tree holding cached nodes at instant under those protocols, and failed on this branch once it was rebased onto master. A partly read node now round-trips under every protocol. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rked batches A YAML case's dotted parameter inputs now run on a reform that copies only the nodes on the paths to the parameters it sets and shares the rest of the tree, with its cached at-instant values, with the system the case's reforms select. Before, each distinct set of values built a full clone of the baseline plus a second full copy of its parameter tree, about 4.5 s and 1.2 GB on policyengine-us, and evicted the structural reform systems the cache holds. The runner's `read_only_arrays` option (`--read-only-arrays`) stores every array read-only for the run, so a formula that writes into an array it read from another variable fails at that line. `forked_test_runner.run_batches_in_forks` runs test batches in children forked from one process that has built the tax-benefit system, so batches skip the import and build and share the parent's system in memory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ding A YAML case's reform system was built on a fresh full clone of the baseline, which the reform kept as its `baseline`, so every cached reform system held two copies of the parameter tree. A reform named by its path, or a Reform class, copies the tree, entities and variable registry before `apply` changes them and leaves the system it is applied to as it was, so these are now built on the shared reform-free system. A reform function is still applied to a clone. The cache also drops its least recently used systems before building a new one, not after, so the cache and the system being built never hold more than `reform_cache_size` reform systems. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk
force-pushed
the
fix-test-runner-memory
branch
from
October 14, 2026 14:15
91744d3 to
2026977
Compare
This was referenced Oct 14, 2026
modify_parameters gives the modifier a deep copy of the baseline's parameter tree, and the copy kept every value the baseline had cached at an instant. A modifier that changes values or brackets in place (the country template's modify_social_security_taxation) then read the baseline's values at any instant the baseline had already read. The YAML runner now builds reforms on the reform-free system other cases have read, so the country template's reform cases failed after any earlier case read 2017. The same happens on master for a reform applied to a system that has already computed. #584's reform test checked that the copy kept the baseline's cached vectorial node. The copy now keeps no cache, so the test reads the values first and then checks the node the read built. It passes with and without this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk
force-pushed
the
test-runner-perf
branch
from
October 14, 2026 14:30
9a1cd9a to
78ca7d8
Compare
…ts after it Reform.modify_parameters deep-copied the baseline's tree. That copy kept the baseline out of a modifier's edits until #459 made Reform.__init__ clone the tree, so it had become a second full copy (5.7 s for policyengine-us's tree, against 3.9 s for the clone). Because each call started again from the baseline, it also dropped any change apply() had made before it, which apply_reform_set keeps. Reform now inherits TaxBenefitSystem.modify_parameters, which hands the modifier the system's own tree, so a reform instance and an in-place reform behave the same. That method now clears every node's cached instants, and the system's, after the modifier runs: a modifier that edits values, brackets or children in place, rather than through Parameter.update, read stale values on the in-place path too. Fixes #618. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TaxBenefitSystem.get_parameters_at_instant cached each instant's node on top of the tree's own cache. Parameter.update clears the tree's caches up to the root but never the system's, so the system went on returning the node it had cached before the change: a consistent old snapshot when nodes were built up front, and with #570's lazy nodes a mix of old values for children read before the change and new ones for the rest. The system now uses a cached node only while the root still caches the same node, which costs a dictionary lookup per call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review found that lazy nodes made existing gaps in cache invalidation visible as results that depend on what was read before: - A freshly loaded scale never set its brackets' parent, so Parameter.update on a bracket's rate or threshold stopped clearing at the bracket. ParameterScale now sets it, as clone already did. - ParameterNode.add_child cleared no cache. It now clears its node's and every ancestor's. - New ParameterNode.clear_instant_caches clears a subtree and the nodes above it, for direct edits to values_list, brackets or children. TaxBenefitSystem calls it once its parameter operations, which edit values in place after reforms have run, are done. A Hypothesis property interleaves reads, updates (scale brackets included) and add_child, and checks that a node requested after the last change reads the tree as it is. Also: - Pickling or deep-copying a node resolves its remaining children first, so the copy is a snapshot, as before, rather than carrying the whole parameter tree through the unread children's parents. - A node is only built at a date. parameters(None), which Simulation._calculate reaches for eternal variables, used to fail on the first scale and cache nothing; a lazy node was cached at "None". - A forked child gets a fresh resolution lock, so a fork while another thread is resolving a child no longer deadlocks the child. 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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
add_child now clears the caches above the node it changes, which first happens while that node is still being loaded. A child named "parent" (policyengine-us has gov.hhs.medicaid.eligibility.categories.parent) replaces the node's parent attribute through add_child's setattr and points back at it until the node is attached, so the recursive walk looped until RecursionError and policyengine-us failed to load. clear_parent_cache now walks up iteratively and stops at a node it has already cleared; scales, which cache nothing, are passed through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#570 now provides the walk, so modify_parameters uses it instead of its own. 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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk
force-pushed
the
fix-test-runner-memory
branch
from
October 15, 2026 20:23
5b41542 to
ebef3bf
Compare
Collaborator
Author
|
Closing without merging. This PR relied on #569 and #570, which are parked, and its own changes also depend on unwritten rules about parameter caches: per-case path copies share untouched subtrees and their cached nodes with the reform-free system. #621 proposes those rules, and the runner changes here (path copies, reform systems built on the reform-free system, forked workers) can be revisited on top of them. #618, which this PR also fixed, stays open; #621 covers it. |
4 tasks
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 #615
Fixes #618
Important
This PR needs both #569 and #570 to merge first. Merge in this order: #569, then #570, then this PR.
fix-test-runner-memory), and the code builds on Stop the YAML test runner keeping every case's simulation and reform system #569's reform cache.pr.yamlonly runs on pull requests intomaster, so this PR gets no checks until Stop the YAML test runner keeping every case's simulation and reform system #569 merges and GitHub retargets it tomaster.Problem
The YAML test runner uses too much memory and CPU for policyengine-us to run its YAML suites without 21 CI shards. #569 and #570 fix two causes. This PR fixes three more:
Change
parameters/path_copy.py,_ParameterInputReformintools/test_runner.py). A case that sets parameters runs on a reform of its own. It copies only the nodes on the paths to the parameters it sets and shares the rest of the tree with the system it is built on. These systems are no longer cached, so inline values no longer key the reform cache.Reformgets a_copy_parametershook, which by default still clones the whole tree.Reformsubclass or a dotted name, because those copy what they change. A reform given as a function still gets a clone. On a cache miss, the cache now evicts before it builds, so it never holdsreform_cache_size + 1systems at once.--read-only-arrays(options["read_only_arrays"],InMemoryStorage.read_only_arrays). The runner stores arrays read-only, so a formula that writes into an array it has read raises an error. Without this, the bad write would silently change the cached value.policyengine_core.tools.forked_test_runner.run_batches_in_forks:gc.collect()andgc.freeze();workerschildren, each running one batch withrun_testsand exiting withos._exit;os.fork, so it is POSIX only, and callers should cap BLAS threads at 1.modify_parameters(modify_parameters reads stale cached values after in-place edits, and Reform.modify_parameters deep-copies a tree the reform already owns #618).Reformno longer overridesmodify_parameters. It inheritsTaxBenefitSystem.modify_parameters, which gives the modifier the reform's own tree: the copyReform.__init__already makes. A reform built as an instance therefore behaves the same as one applied withapply_reform_set.ParameterNode.clear_instant_caches. Without this, a modifier that edits values, brackets or children in place, like the country template'smodify_social_security_taxation, read the values the tree had cached. The runner hit that once reforms were built on the reform-free system other cases had read.apply()are now kept, and successivemodify_parameterscalls build on each other.docs/engineering/skills/testing.mdis updated, andchangelog.d/test-runner-perf.added.mdis added.Test outcomes are unchanged. A Hypothesis test checks that path-copied parameters read the same as a full clone at every instant.
test_parameter_inputs.pychecks that dotted inputs give the same results as the old full-clone reform, with and without a named reform. I also built each of the 152 distinct reform lists in policyengine-us's YAML tests on the reform-free system: none of them changed its variables, formulas, entities or any parameter value. That audit checked only whether a reform changes the shared system. It did not check whether a reform still reads its own changes; themodify_parametersfix covers that case.Measurements
#569 and #570 are now rebased onto
master(b909d5a), so this branch includes it. Its code is what I measured plus three later changes: #570's pickling test, themodify_parameterscache fix and themodify_parameterschange for #618. With all three, the whole policyengine-us YAML suite (34,843 cases) passes forked. So do the 29 policyengine-us Python test files that build or apply reforms: 472 passed, 4 skipped. policyengine-us applies its reforms in place, so these runs clear the cache on every system it builds.The policyengine-us side is a change to
test_batched.py:--fork --workers 4usesrun_batches_in_forkswith a reform cache of 1 and read-only arrays. It will be opened as its own PR after a core release.Same macOS host. "Before" is policyengine-us
mainon core 3.33.3. Every case passed in every run:The host was loaded, so CPU seconds are the more reliable figure. macOS also counts pages shared between forked workers once for each worker, so its after-peaks overstate memory.
The memory figures come from a Linux container capped at 7.5 GB, using cgroup
memory.peak:--read-only-arrays, peaking at 6.6 GB with 2 workers.Two figures were measured before the reform-system change, which mainly affects contrib:
Tests
On this head (
b8aa6e6a), which merges #569's and #570's October 15 fixes, on macOS with Python 3.14:pytest tests: 2,662 passed, 4 skipped, 3 xfailed.ruff format --check .andruff check .pass.--read-only-arraysand with--reform-cache-size 1.Correction. An earlier version of this description said the country template YAML suite passed with and without
--read-only-arrays. That run came before the reform-system change. After that change, 3 of its 39 cases failed (themodify_social_security_taxationcases) until themodify_parametersfix.New and changed tests:
tests/core/parameters/test_path_copy.pytests/core/tools/test_runner/:test_parameter_inputs.py,test_read_only_arrays.py,test_forked_test_runner.pyandtest_reform_system_size.pytests/core/test_reforms.py, these fail onmaster:test_modify_parameters_reads_its_changes_after_the_baseline_cached_themtest_an_in_place_reform_reads_its_changes_after_the_system_cached_themtest_modify_parameters_keeps_what_apply_changed_before_ittest_a_reform_instance_and_an_in_place_reform_give_the_same_parameterstest_modify_parameters_leaves_the_baseline_unchangedpasses on both.test_reform_cache_property.pynow checks that no reform is ever applied to the shared reform-free system.test_reform_that_modifies_parameters_keeps_the_cached_vectorial_nodeis renamed..._reads_the_vectorial_node, as described above.test_tracing_parameter_isolation.pyandtest_vectorial_parameter_node_copy.pyno longer saymodify_parametersdeep-copies the tree.Not run:
make documentation, and anything in real CI (see the note at the top).🤖 Generated with Claude Code