Skip to content

Copy only set parameters for YAML inputs, share the reform-free tree, and run test batches in forked workers - #616

Closed
anth-volk wants to merge 31 commits into
fix-test-runner-memoryfrom
test-runner-perf
Closed

anth-volk wants to merge 31 commits into
fix-test-runner-memoryfrom
test-runner-perf

Conversation

@anth-volk

@anth-volk anth-volk commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

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.

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:

  • each distinct combination of dotted parameter inputs cloned the whole tax-benefit system, costing about 4.5 s and 1.2 GB;
  • each reform system held two copies of the parameter tree;
  • parallel batches could not share one built system.

Change

  • Dotted parameter inputs (parameters/path_copy.py, _ParameterInputReform in tools/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. Reform gets a _copy_parameters hook, which by default still clones the whole tree.
  • Reform systems are built on the cached reform-free system instead of a clone of the baseline, so each one holds a single copy of the parameter tree. This applies when every reform is a Reform subclass 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 holds reform_cache_size + 1 systems 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:
    • builds the reform-free system once, then runs gc.collect() and gc.freeze();
    • forks up to workers children, each running one batch with run_tests and exiting with os._exit;
    • enforces a per-batch timeout (SIGTERM, then SIGKILL) and reports each child's exit code, peak RSS and output;
    • children share the parent's system copy-on-write;
    • it requires 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).
    • Reform no longer overrides modify_parameters. It inherits TaxBenefitSystem.modify_parameters, which gives the modifier the reform's own tree: the copy Reform.__init__ already makes. A reform built as an instance therefore behaves the same as one applied with apply_reform_set.
    • That method now also clears every node's cached instants after the modifier runs, with Resolve parameter nodes at instant on first read #570's ParameterNode.clear_instant_caches. Without this, a modifier that edits values, brackets or children in place, like the country template's modify_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.
    • For reform instances, changes made earlier in apply() are now kept, and successive modify_parameters calls build on each other.
    • Building such a reform no longer deep-copies the tree, which took 5.7 s for policyengine-us's.
    • modify_parameters reads stale cached values after in-place edits, and Reform.modify_parameters deep-copies a tree the reform already owns #618 has the history and a survey of downstream usage.
    • Let vectorial parameter nodes be copied and pickled #584's reform test asserted that the copy kept the baseline's cached vectorial node. It now reads the values before checking the node, and passes with and without this change.
  • Docs and changelog. docs/engineering/skills/testing.md is updated, and changelog.d/test-runner-perf.added.md is 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.py checks 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; the modify_parameters fix 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, the modify_parameters cache fix and the modify_parameters change 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 4 uses run_batches_in_forks with 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 main on core 3.33.3. Every case passed in every run:

policyengine-us shard Before: wall, CPU, peak This stack, 4 forked workers: wall, CPU
Baseline states 2/4, 6,724 cases 2,088 s, 1,548 s, 10.4 GB 97 s, 284 s
Contrib states 4/4, 215 cases 3,401 s, 2,560 s, 12.0 GB 56 s, 156 s

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:

  • The whole policyengine-us YAML suite (34,843 cases) passed forked, with and without --read-only-arrays, peaking at 6.6 GB with 2 workers.
  • Each contrib worker uses about 2.5 GB of private memory, so policyengine-us runs contrib with 3 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.
  • The whole policyengine-us YAML suite passes forked (34,843 cases).
  • The 29 policyengine-us Python test files that build or apply reforms, or change parameter trees, pass: 472 passed, 4 skipped.
  • A differential check of 1,200 runner requests (40 random sequences of 30, mixing reforms, dotted inputs, cache sizes 0 to 2 and partial reads) matches freshly built systems.
  • ruff format --check . and ruff check . pass.
  • The country template YAML suite passes with default options, with --read-only-arrays and 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 (the modify_social_security_taxation cases) until the modify_parameters fix.

New and changed tests:

  • tests/core/parameters/test_path_copy.py
  • tests/core/tools/test_runner/: test_parameter_inputs.py, test_read_only_arrays.py, test_forked_test_runner.py and test_reform_system_size.py
  • In tests/core/test_reforms.py, these fail on master:
    • test_modify_parameters_reads_its_changes_after_the_baseline_cached_them
    • test_an_in_place_reform_reads_its_changes_after_the_system_cached_them
    • test_modify_parameters_keeps_what_apply_changed_before_it
    • test_a_reform_instance_and_an_in_place_reform_give_the_same_parameters
  • test_modify_parameters_leaves_the_baseline_unchanged passes on both.
  • test_reform_cache_property.py now checks that no reform is ever applied to the shared reform-free system.
  • Let vectorial parameter nodes be copied and pickled #584's test_reform_that_modifies_parameters_keeps_the_cached_vectorial_node is renamed ..._reads_the_vectorial_node, as described above.
  • Docstrings in test_tracing_parameter_isolation.py and test_vectorial_parameter_node_copy.py no longer say modify_parameters deep-copies the tree.

Not run: make documentation, and anything in real CI (see the note at the top).

🤖 Generated with Claude Code

MaxGhenis and others added 18 commits October 14, 2026 18:05
…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>
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 and others added 6 commits October 14, 2026 20:04
…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>
anth-volk and others added 6 commits October 15, 2026 01:53
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

Copy link
Copy Markdown
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.

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.

2 participants