Skip to content

Keep solved values on the host or on disk (#591) - #633

Open
mj023 wants to merge 41 commits into
codex/570-singleton-initialfrom
feat/591-value-storage
Open

mj023 wants to merge 41 commits into
codex/570-singleton-initialfrom
feat/591-value-storage

Conversation

@mj023

@mj023 mj023 commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

Implements #591: ExecutionConfig(value_storage=...) keeps solved values in pinned host memory or on disk, so the device holds only the resident window instead of every solved period.

Stacked on #630 (codex/570-singleton-initial). The diff also carries the #590 commits that the stack does not have yet.

Summary

ExecutionConfig(value_storage="device")      # default, unchanged
ExecutionConfig(value_storage="host")        # pinned host memory
ExecutionConfig(value_storage=Path("dir"))   # one archive per solve, SolutionResult.save format

Solve, period-major, per period t:

 run kernels of t
-keep V[t] on the device until solve returns
+copy V[t] and the EGM-family policies of t to pinned host memory, without blocking
+  (under a directory, a writer thread then writes them to solution-<id>.h5)
+NaN/Inf flags of t become host booleans; at the first NaN, validate_V runs now,
+  while V[t] and V[t+1] are still on the device
+wait for the copy of t+1, so at most 3 periods are on the device
  • Planner: budget admission charges only the resident window. Under "device", a refusal caused by stored values names value_storage="host".
  • Simulation:
    • With several chunks it runs period by period, chunks inside.
    • Reads go through a caching device view, so each period's values and policies are copied once for every chunk.
    • The next period's declared reads start copying before a period's chunks run.
    • Under a budget the planner reserves every subject's carry and that read set.
  • Block-major: each code is solved with host storage. Under a directory, all codes' blocks go into one archive.
  • Failures: a failed copy or write raises ValueStorageError, naming the period, the regime and the target. Under a directory the file stays as solution-<id>.incomplete, recording fingerprints, the pylcm version and commit, the dtype and the last completed period.

Evidence

Every outline test in #591 is in tests/solution/test_value_storage.py and tests/solution/test_value_storage_sharded.py:

  • Bitwise equality: device, host and directory storage give bitwise-equal values, EGM policies and panels. Checked on the stochastic 4-period model, with one chunk and with several, and on NB-EGM.

  • Resident window:

    live device values at each kernel, three-regime model
    "device": every solved period stays  -> window check fails, as intended
    "host":   only {t, t+1, t+2}         -> window check passes
    
  • Round trips: a disk archive is solved in one process and loaded and simulated in another: one device to one, two sharded to one, one to two sharded.

  • Failures: an injected write failure names the period, the regime and the .h5.partial path. Before this change the raw OSError came through, and the record said period 0 instead of 2, because the writer kept writing after the failure.

  • Fewer copies:

    • With several chunks, policy copies now equal one chunk's. Before: 19 vs 11 under "host", and 8 vs 4 disk reads.
    • A NaN under host or disk storage reads no value back. Before, 2 values were read back.
  • Blocking references: blocked host storage matches the unblocked solve, and block-major in every mode matches period-major, within 2 ULP. That is the gap blocking itself causes; nothing else shrank.

  • Local runs (after merging Honor finite floors in paired maximum reductions #630 at 1ed7559d2):

    Where fp64 fp32
    CPU 431 passed, 16 skipped (15 value-storage, archive, persistence, simulation and diagnostics files) 95/95
    CPU, four devices 16/16 (test_distributed_simulation_values.py, the 16 skipped above)
    Local RTX 3050 89/89 (value-storage and archive files, before the merge) 89/89

CI

CI ran the full suite on 2ca4c2c05: 41 jobs pass, 23 fail. Every failure comes from the base #630:

  • All 35 failing tests fail with the same errors on Honor finite floors in paired maximum reductions #630's CI at 1ed7559d2; which shard they land in differs.
  • This PR changes none of the failing test files. The value-storage tests pass in every job, including macOS, Windows, fp32 and the four- and eight-device jobs.
  • The failures, grouped by cause:
    • NaN values in solved value functions: test_fgp_model, test_economic_validation, test_egm_passive_asset_row, test_process_simulation_laws, test_unbudgeted_subject_width[9000].
    • The simulate_draws= → simulated_draws= rename: test_density_derivative_accuracy still passes the old name.
    • Memory and size limits:
      • limits exceeded: test_regime_mixture_reduction, test_invariant_assembly_reservation;
      • budget refusals: test_current_iid_grid_search, test_policy_lookup_devices[8].
    • An XLA FAILED_PRECONDITION error on the GPU: test_public_exit_queries in the bounded GPU job.
    • Crashed test workers: test_participation_pricing_depth::test_a_saved_solution_… and test_collective_household_example. The first passes locally in 290 s.
    • Single failures: test_collective_extended_real, test_participation_read_alias_process_code[solve], test_outer_candidate_drop_report, test_block_major_lifetime, and the regression output for precautionary_savings_health.
  • The GPU benchmark job also fails on Honor finite floors in paired maximum reductions #630.

These are left to #630 and should clear here once it is fixed and merged in.

Device memory, measured

Setup:

  • RTX 3050, fp64, default budget.
  • One grid-search regime: 40,000 wealth points × 25 preference types × 40 consumption points, then a terminal regime.
  • Each run in a fresh process; the figures are the device allocator's peak_bytes_in_use during the solve and bytes_in_use after it.
Periods Values Storage Peak during solve Held after solve Solve time
60 491 MB "device" 1786 MB 492 MB 7.4 s
60 491 MB "host" 1312 MB 0.6 MB 7.4 s
60 491 MB directory 1312 MB 0.6 MB 19.7 s
120 990 MB "device" 2285 MB 991 MB 15.0 s
120 990 MB "host" 1312 MB 0.6 MB 14.6 s
  • Peak: under host or disk storage the peak stays flat as periods grow. Under "device" it grows by every period's values: −474 MB at 60 periods and −973 MB at 120, about all values but the resident window. What remains is the period's workspace.
  • Time: copying to pinned host memory costs no solve time here. Writing to disk costs about 2.7× on this machine.

Merge Danger

Door: two-way

"device" stays the default, and with it nothing changes except one thing every mode shares: the NaN check now runs at each period's barrier instead of after the loop. The disk archive is in the existing SolutionResult.save format.

Blast Radius: moderate

  • Every mode:
    • The diagnostics change touches all solves.
    • The engine view's policies are now a caching view under host or disk storage.
    • Artifact leaves in pinned host memory are copied onto the device when read.
  • Known limits:
    • The early load runs only when a simulation has several chunks.
    • Disk storage gathers each value on one host before writing it.
    • Component jobs accept "device" only.
    • Budgeted runs of the NB-EGM toy and the blocked test model are refused on a GPU under any storage. That predates this PR, so off-device policies are untested under a budget.
  • Certificate: pins and seals are updated after the merge, and verify.py passes.
  • Not included: the issue comment's re-check of only the refused cell after a narrowing is left to its own change.

🤖 Generated with Claude Code

mj023 and others added 14 commits October 26, 2026 20:09
Brings #603's commits after a9ea2b8 (acc1c8f, df3fa3c, 8cc8177, bf44317)
onto the stack head 965e772 (#630).

Conflicts:
- backward_induction.py: _internal_reservations_by_cell keeps the stack's
  native-schedule and host-buffer bookkeeping and #603's invariant-assembly
  reservation; marked noqa C901 since the two together exceed the limit.
- ci-workloads.json: stack side plus #603's measured weights for
  test_capture_padding.py and test_invariant_assembly_reservation.py; shards
  regenerated.
- Certificate: stack pins, _build_zero_V_arr pin renamed to
  _build_V_arr_template as in acc1c8f, corridors re-pinned for
  backward_induction.py, period_replay.py and v_topology.py, seals fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ExecutionConfig.value_storage="host" copies each period's values to pinned host
memory right after the period's kernels, so the copy runs while the next period
computes. Before the next period is dispatched the solve waits for the copy
started one period earlier, so the device holds at most the period being solved,
the period it reads and one whose copy is finishing. Reads hand out device
copies: the value store wraps a host value in a lazy entry, and simulation and
the NaN breakdown read through a view that keeps two periods on the device.
Values, reads and panels equal device storage bit for bit. Host storage is
refused with the block-major schedule, which has its own route to the host.

Policies, artifacts and dissolution flags, the planner's resident charge and the
materialize refusal follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under value_storage="host" a collective regime's dissolution flags are copied
to pinned host memory with the period's values and are part of the wait that
bounds the device. Simulation reads them back through the device view; the
public replay store holds them on the CPU device, where policies already are.

The read-through view now keeps the three periods read most recently: replaying
a same-period exit at period p reads p, p+1 and p+2, and dropping a period still
in use broke the simulation's check that a source stays the same array.

Policies and persistable artifacts keep their copy to the CPU device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under value_storage="host" the residency walk no longer treats solved values
as kept until the solve returns. A value of period q counts as read by every
dispatch of periods q and q-1, so admission charges it while its peers run and
while the next period reads it and its copy to the host finishes, which is
when the solve frees it. Only the residency walk reads this ledger: release
and donation keep the device ledger, so no value whose copy is in flight is
handed to another owner.

Under device storage a cell's residency refusal now names
ExecutionConfig(value_storage='host').

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

A host-stored value entry carries the solve's per-device budget, and the value
store admits a full materialization against it: placing every value on the
devices at once is refused before anything loads, naming value_storage and the
remedies, when the summed bytes on a device exceed the budget.

Tuning records key on value_storage, since storage decides what the planner
charges. A record written before the field existed decodes as device storage,
the only mode there was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each period's copy to the host is dispatched after the period's kernels and
before the next period's, which is what lets it run beside the next period.
A blocked period-major solve and a solve sharded over two CPU devices publish
the device-storage values and panels bit for bit; sharded values keep their
sharding in host memory and are read back onto it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ExecutionConfig.value_storage takes an existing directory besides "device" and
"host"; value_storage_kind names it "disk", which the tuning key records in
place of the path. A directory is refused with the block-major schedule, as
"host" is.

StreamedSolutionArchive writes a solve's values one at a time, with their
checksums computed as they are written, into <name>.h5.partial. Completing it
adds the result's artifacts and manifest through the code SolutionResult.save
uses and renames it to <name>.h5; abandoning it writes a record in place of the
manifest and renames it to <name>.incomplete, which load_solution refuses.
DiskValueEntry reads a written value back, verified, onto its sharding.

The solve does not write to the directory yet: a directory behaves like "host"
until the next change wires the archive in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With a directory as ExecutionConfig.value_storage, each solve writes its values
to solution-<id>.h5 in it. A background thread writes each period's host copies
and the solve swaps them for entries that read the value back from the file;
the solve waits while two periods are still unwritten, which bounds host
memory. The returned result reads its values from the archive, and its file is
deleted once nothing reads them; SolutionResult.save copies it. A failed solve
leaves solution-<id>.incomplete with the last written period, the dtype, the
model and parameter fingerprints and the pylcm version.

The structural blueprint key now takes the storage kind, not the directory:
before this change a directory could not be solved at all, contrary to the
previous commit's message.

Model._solve_from_flat_params owns the archive and delegates to _solve_into.
Certificate declarations edited by hand: the compiled_solve_owners_omitted
anchor includes its preceding line, and Model._solve_into is pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The per-chunk period loop becomes a generator, _subject_chunk_periods, that
pauses after each period; _simulate_subject_chunk runs it to its end, so the
chunk-outer path is unchanged. With values read through the host read-through
view and several ungrouped, unbudgeted chunks, _simulate_windows_period_by_period
runs every chunk through a period before any starts the next: the first chunk's
read copies a value onto the device and later chunks reuse it, so a period's
values cross over once, as with a single chunk. Each chunk's results of a period
go to the host when the period is done; dissolution records keep chunk order.

Budgeted runs still use the chunk-outer loop until the budget accounts for every
chunk's carry. No eager prefetch: prefetching whole periods copied values no
subject reads.

SubjectChunkKwargs and SubjectChunkResults move from tests/conftest.py into
simulate.py. Certificate declarations edited by hand: the generator and the
driver are pinned wherever _simulate_subject_chunk is.

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

Budgeted simulation refused every host- or disk-stored solution: the walk that
charges what a solution keeps on the device did not know their entries. Host and
disk entries now pin nothing, and a read-through view is charged only the device
copies it keeps.

Budgeted calls with values kept off the device now run their chunks period by
period too. The planner admits the chunk width beside every subject's carry,
which grows with the population rather than the width, and refuses up front,
naming fewer subjects per call or a larger budget, when it does not fit. While a
chunk runs, the waiting chunks' carries are a memory owner of their own, so every
dispatch admission counts them; each chunk is admitted against its profile before
its first period.

Planning no longer copies stored values onto the device: footprints count a
view's cached copies, the chunk profiler reads shape stand-ins the view carries,
and boundaries that only pass the values along get runtime-coarse types, since a
runtime check samples one entry and sampling a view copies a value. A budgeted
call now makes the copies an unbudgeted one makes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under the block-major schedule, value_storage="host" solves each code with host
storage, so the device holds the period window of one code, and the retention
keeps the pinned copies that solve made instead of copying them to NumPy.
Deleting a code's blocks frees device memory only. A combined solve and
simulate retains each host-stored code at once and hands its subjects an upload.
"device" keeps block-major as it was. A directory does not combine with
block-major yet, and component jobs refuse any storage but "device".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With a directory as value storage, a block-major solve now writes each code's
blocks into one archive per solve and keeps none in memory. Each code is solved
with host storage, so the device holds one code's period window. The archive
holds one full dataset per value, and each code's block is written into its
slice along the blocked axis; checksums are computed at completion by reading
each value back once, so the file is an ordinary saved solution that
load_solution reads and SolutionResult.save copies.

A failed solve leaves solution-<id>.incomplete with the codes already written.
An archive never finished nor abandoned, as when a combined solve and simulate
fails, is closed and kept as incomplete once nothing refers to it. The two
archive kinds share one base for describing, abandoning and completing.
Component jobs still take device storage only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings the 77 commits of the stack top onto the value-storage work.

Conflicts: chunk_profiles keeps the projected-forward-input branch with the
widened read-source type; entry_allocations keeps the runtime-coarse resolved
inputs with the six-element tuple; imports and the block-major schedule keep
both sides; the invariant-assembly check takes the stack's equivalent form.

Without textual conflicts: the host-storage ledger call drops
retain_all_artifacts, which the stack removed, and generation and
validation_materializer, added to _solve_from_flat_params, now reach
_solve_into, where the code reading them lives.

Certificate: the stack's declarations, with Model._solve_into and the
period-by-period simulation helpers registered again; corridors re-pinned for
the sources this branch changes. verify.py still reports eager_stages.py
missing from the seal map, as it does on the stack top itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Failed copies and writes raise ValueStorageError naming the period,
  regime (and code) and the target; the disk writer stops after its first
  failure so the incomplete record's last completed period is right. The
  record now carries the pylcm commit.
- EGM-family simulation policies take the value path: copied to pinned host
  memory without blocking, written to the archive with the values under a
  directory (reused as the saved artifact at completion), and read through
  the caching device view, so all chunks of a period share one copy.
- validate_V runs at each period's barrier while the values are on the
  device; the post-solve pass reads only per-row flags.
- The period-by-period simulation starts copying the next period's declared
  reads before a period's chunks run; the planner reserves that read set.
- Membership checks and beartype sampling on read-through views no longer
  copy values onto the device.
- Tests: new-process and sharded disk round trips, a stochastic test-1
  model, test 9 against the issue's references within blocking's 2 ULP,
  transfer start/end ordering, lookup_policy and snapshot reads; tests that
  rely on several chunks or on unbudgeted NB-EGM/blocked runs ask for no
  budget explicitly. Docs and CHANGES updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@read-the-docs-community

read-the-docs-community Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

mj023 and others added 7 commits October 31, 2026 15:17
The `_Prefetch` protocol in its signature was defined below it and was not
runtime-checkable, so beartype skipped the function with a warning and it
ran without its runtime type checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The host-side policy check reads each disk-stored policy only when it
  looks it up, instead of loading every period's policies into host memory
  at once before simulating.
- A failure record takes the pylcm commit from git only when pylcm's own
  checkout is the repository root, not from a project repository an
  installed pylcm happens to sit in.
- A read-through view's registry entry goes with the view itself, so a new
  mapping that reuses a dead view's id is never taken for it while one of the
  view's periods is still alive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Each period's barrier moves every regime's feasibility counts, read counts
  and NaN/Inf flags to the host in one transfer instead of one per regime.
- Dissolution flags kept in host memory go into the result's replay store as
  they are; the store hands out device copies when read, as for policies.
  Removes to_cpu_device and _on_cpu_device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Values loaded early from disk are read by a background thread, so the
  file reads overlap the period instead of delaying its chunks.
- The early copies are charged in the runtime ledger as parked beside the
  waiting chunks' carries, so the per-dispatch budget check sees them.
- A relative value_storage directory is resolved when the config is built.
- One host-copy function covers values and policies.
- One set of shared runtime-coarse aliases (StoredValues, DeclaredValues,
  StoredPolicies) replaces five private copies.
- The NaN check walks back only through the current period's rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The base removed the solve-time dissolution flag and its artifact key, the
current-IID replay inputs and the projected-forward inputs of the chunk
planner, and added participation surfaces to the result and its archive.
Value storage follows: no flag is copied to the host, published, read early
or reserved; the read declarations and the early load cover values and
policies; the period-by-period simulation passes the chunks the base's new
participation surfaces and node-axis inputs; and the streamed archive writes
participation surfaces at completion, as a save does. Certificate pins and
seals rebuilt on the base; CI manifest regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	tests/candidate_certificate/direct_flow.py
#	tests/candidate_certificate/sources.json
#	tests/ci/ci-workloads.json
# Conflicts:
#	tests/candidate_certificate/direct_flow.py
#	tests/candidate_certificate/sources.json
#	tests/ci/ci-workloads.json
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mj023
mj023 marked this pull request as ready for review November 2, 2026 19:13
# Conflicts:
#	tests/candidate_certificate/sources.json
# Conflicts:
#	tests/candidate_certificate/direct_flow.py
#	tests/candidate_certificate/sources.json
…t/591-value-storage

# Conflicts:
#	tests/candidate_certificate/direct_flow.py
#	tests/candidate_certificate/sources.json
#	tests/ci/ci-workloads.json
…20261103

# Conflicts:
#	tests/candidate_certificate/direct_flow.py
#	tests/candidate_certificate/sources.json
#	tests/ci/ci-workloads.json
@mj023
mj023 requested review from hmgaudecker and timmens November 4, 2026 15:53
@codecov

codecov Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.55382% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.15%. Comparing base (a831dc1) to head (e9c483a).

Files with missing lines Patch % Lines
src/_lcm/persistence/solution.py 95.30% 18 Missing ⚠️
src/_lcm/solution/host_values.py 97.88% 4 Missing ⚠️
src/_lcm/solution/backward_induction.py 95.74% 2 Missing ⚠️
src/_lcm/simulation/simulate.py 98.36% 1 Missing ⚠️
Additional details and impacted files
@@                       Coverage Diff                       @@
##           codex/570-singleton-initial     #633      +/-   ##
===============================================================
+ Coverage                        94.12%   94.15%   +0.02%     
===============================================================
  Files                              660      662       +2     
  Lines                           111076   111997     +921     
===============================================================
+ Hits                            104554   105449     +895     
- Misses                            6522     6548      +26     
Flag Coverage Δ
cpu-python 94.15% <97.55%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Benchmark comparison (main → HEAD)

Comparing 0fb55673 (main) → e9c483ac (HEAD)

Benchmark Statistic before after Ratio Alert
ACA (reduced) warm solve + simulate (reuses compiled code) 105.722 s 245.611 s 2.32 ❌
cold solve + simulate (first run, includes compilation) 808.56 s 1695.49 s 2.10 ❌
peak CPU mem 6.16 GB 9.17 GB 1.49 ❌
Mahler-Yum warm solve + simulate (reuses compiled code) 6.054 s 9.222 s 1.52 ❌
cold solve + simulate (first run, includes compilation) 19.02 s 36.19 s 1.90 ❌
peak CPU mem 1.66 GB 1.78 GB 1.07
peak GPU mem: automatic solve + simulate 1.61 GB 714 MB 0.44
peak GPU mem: load saved solution + simulate 1.89 GB 837 MB 0.44
peak GPU mem: solve + save all persistable artifacts 571 MB 714 MB 1.25 ❌
Precautionary Savings - Solve execution time 84.0 ms 89.1 ms 1.06
first call (including compilation) 1.29 s 2.00 s 1.55 ❌
peak CPU mem 1.44 GB 1.45 GB 1.00
Precautionary Savings - Simulate execution time 901.9 ms 1.199 s 1.33 ❌
peak GPU mem 324 MB 690 MB 2.13 ❌
first call (including compilation) 3.40 s 5.01 s 1.47 ❌
peak CPU mem 1.57 GB 1.58 GB 1.01
Precautionary Savings - Solve & Simulate execution time 661.7 ms 1.007 s 1.52 ❌
peak GPU mem 1.51 GB 4.24 GB 2.81 ❌
first call (including compilation) 4.28 s 6.83 s 1.60 ❌
peak CPU mem 1.53 GB 1.55 GB 1.01
Precautionary Savings - Solve & Simulate (irreg) execution time 802.4 ms 1.110 s 1.38 ❌
peak GPU mem 2.21 GB 4.24 GB 1.92 ❌
first call (including compilation) 4.45 s 7.40 s 1.66 ❌
peak CPU mem 1.53 GB 1.55 GB 1.01
Iskhakov et al. (2017) - GridSearch Solve execution time 195.2 ms 226.2 ms 1.16 ❌
peak GPU mem 120 MB 120 MB 1.00
first call (including compilation) 2.43 s 3.30 s 1.36 ❌
peak CPU mem 1.63 GB 1.64 GB 1.01
Iskhakov et al. (2017) - DC-EGM Solve execution time 826.2 ms 1.077 s 1.30 ❌
first call (including compilation) 6.87 s 8.58 s 1.25 ❌
peak CPU mem 1.99 GB 1.97 GB 0.99
Iskhakov et al. (2017) - GridSearch Simulate execution time 918.0 ms 1.215 s 1.32 ❌
peak GPU mem 891 MB 890 MB 1.00
first call (including compilation) 7.02 s 8.79 s 1.25 ❌
peak CPU mem 1.76 GB 1.77 GB 1.00
Iskhakov et al. (2017) - DC-EGM Simulate execution time 1.281 s 1.850 s 1.44 ❌
peak GPU mem 891 MB 891 MB 1.00
first call (including compilation) 9.25 s 11.42 s 1.24 ❌
peak CPU mem 2.03 GB 2.08 GB 1.02
Collective Household - Construct execution time 1.547 s 1.805 s 1.17 ❌
peak CPU mem 1.43 GB 1.43 GB 1.00
Collective Household - Solve execution time 5.453 s 6.216 s 1.14 ❌
first call (including compilation) 55.91 s 55.95 s 1.00
peak CPU mem 4.60 GB 2.74 GB 0.60
Collective Household - Simulate (1000) execution time 32.017 s 42.573 s 1.33 ❌
first call (including compilation) 505.24 s 736.88 s 1.46 ❌
peak CPU mem 25.19 GB 28.84 GB 1.15 ❌
Collective Household - Simulate (100000) execution time nan µs nan µs nan
peak GPU mem nan MB nan MB nan
first call (including compilation) nan s nan s nan
peak CPU mem nan MB nan MB nan
Reference Chain - Solve (1) execution time 39.8 ms 58.0 ms 1.46 ❌
first call (including compilation) 1.48 s 2.11 s 1.43 ❌
peak CPU mem 1.16 GB 1.19 GB 1.02
Reference Chain - Solve (8) execution time 253.5 ms 371.6 ms 1.47 ❌
first call (including compilation) 5.35 s 7.70 s 1.44 ❌
peak CPU mem 1.29 GB 1.32 GB 1.02
SimulationDispatch ('dissolution', 'off') warm simulation time per period and regime (ms) 78.72 ms 99.75 ms 1.27 ❌
backend compile requests on second simulation call 0
SimulationDispatch ('dissolution', 'progress') warm simulation time per period and regime (ms) 83.99 ms 102.79 ms 1.22 ❌
backend compile requests on second simulation call 0
SimulationDispatch ('multi_regime', 'off') warm simulation time per period and regime (ms) 56.41 ms 72.21 ms 1.28 ❌
backend compile requests on second simulation call 0
SimulationDispatch ('multi_regime', 'progress') warm simulation time per period and regime (ms) 50.58 ms 80.29 ms 1.59 ❌
backend compile requests on second simulation call 0

hmgaudecker and others added 2 commits November 5, 2026 08:40
Host and disk value storage hand the chunk planner a `jax.ShapeDtypeStruct`
per policy leaf in place of the stored array. The finite NNBEGM planning
step only abstracts each leaf to its shape, dtype, weak type and placement,
so it needs no values; its concrete-only checks predate stored policies.
Accept a descriptor wherever a leaf is only described: the payload
annotation, the planner guard, the abstraction callback and the replay read
enumerator, which must name a descriptor leaf as one the replay acquires.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S
Under a device-memory budget the chunk planner receives descriptors of
stored policies. Each case simulates from a host- or disk-stored solve
under a budget and compares the panel to the device-stored one exactly:
the finite NNBEGM route, and the DC-EGM policy-first read with its grid
fallback. Both routes rejected the descriptors before they were admitted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAZ63AjbZFLweWuJyPUs6S

@hmgaudecker hmgaudecker left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

Thank you, the host and disk storage design reads well. The read views hand out device arrays, and the writer thread, the failure latch and the per-solve archive names are easy to follow.

One thing I fixed on the branch, with a test in 53cbc91 and an inline comment on 1a2d304 explaining why. One thing I would like changed before merging: the disk archive's failure handling. It is built on a try/except in Model.solve that this PR introduces, and the three ways it misses its own contract all come from that choice. The archive can record its progress as it goes and then needs no handler. Details below. Line numbers refer to 1a2d304. Where I say something ran, it ran in the branch's locked tests-cpu environment on CPU with x64.

Disk archive: record progress as it happens, drop the handler

Model.solve wraps the solve in try/except BaseException and calls abandon with a record built in the handler (src/lcm/model.py:2778-2820). This PR introduces that handler. I would rather it did not exist, and I think it does not have to.

Every field of the record is known before the handler runs. dtype, params_fingerprint, pylcm_version, pylcm_commit and the model_fingerprint that describe collects at model.py:2860 are known before the first period. last_completed_period and completed_codes are known the moment each period or code finishes, and the archive already tracks them in memory. If the archive writes the static fields into the .h5.partial file when it is created and updates the progress fields as each period lands, then the documented record is on disk at every moment of the solve. A failure anywhere leaves the file with the record already in it. There is nothing to do on the way out, so there is no handler. Completion writes the manifest, flushes, fsyncs and renames, as _close does now. The finalizer's rename of a leftover .partial to .incomplete at solution.py:3729-3740 then completes the picture instead of producing a file with no record.

The three gaps I found are consequences of the handler and go away with it:

  • Model.simulate(solution=None) on the block-major route creates its own BlockedSolutionArchive at model.py:4916-4924 and completes it at model.py:5151-5152, but never abandons it. A NaN value function leaves a .partial that the finalizer renames with no record inside. With the archive owning its record, this route gets the same record as solve for free.
  • abandon writes through the same HDF5 handle that just failed (solution.py:3250-3264), and the call is unprotected. On a full volume the cleanup error replaces the ValueStorageError that names the period and regime. A record written before the failure cannot fail after it.
  • _close closes the handle before fsync and the rename (solution.py:3308-3316). If either fails, abandon calls create_dataset on a closed handle; with h5py 3.16.0 that raises ValueError: Unable to synchronously create dataset (invalid identifier type to function), which replaces the original error. The file at that point holds a complete manifest and payloads, and the loader checks contents (solution.py:606-609), so load_solution would accept it even after the finalizer renames it. I derived this from the code and did not run it. Without a handler there is no second failure, and the question becomes only whether a file with a complete manifest that was never renamed should load. I would say it may, since the payload checksums are verified, but please decide and write it down.

For tests: a block-major simulate(solution=None) with a NaN in the first and in a later code, checked before the exception is released; a solve where the period write fails on a full volume, which must raise the ValueStorageError and leave the record with the last completed period; a solve where fsync fails in _close, which must raise that error and leave a file whose fate is the one you decided above.

Lesser notes

  • The archive is created before the solve starts (model.py:2765-2777), and a read-only directory passes _fail_if_value_storage_invalid (execution.py:522-530). solve then raises h5py's PermissionError for the .h5.partial path rather than a ValueStorageError. I ran this; the directory is left empty, but the error does not name the storage setting.
  • The planning descriptors drop weak_type (host_values.py:549-553 and the two device_stand_in methods in solution.py near lines 3090 and 3183), while the device path keeps it in _placed_abstract (forward_program_profiles.py:870-876). A weakly typed stored leaf would be planned with a different signature than it is dispatched with. I did not check whether any stored leaf is weakly typed; 1a2d304 does not change this.
  • DiskValueEntry.materialize and DiskPolicyEntry.materialize use a plain jax.device_put (solution.py:3111-3116, 3149-3166), while the saved-archive loader refuses implicit x64 narrowing in _to_jax_without_narrowing (solution.py:2237-2248). A change of the x64 flag between solve and read narrows silently on the live path. Please consider the same guard.
  • PeriodValueWriter.submit queues the new period before it waits (solution.py:3640-3655), so a slow writer can hold three unwritten periods in host memory, not two. The wording at execution.py:354-356 is correct but easy to misread; please say three.
  • submit waits on the oldest write with no timeout (solution.py:3650-3653). A write that never returns, on a stalled network volume say, holds the solve forever. If that is intended, please say so in the docstring.
  • The per-period NaN and Inf flags now go to the host in _materialize_period_feasibility_counts (diagnostics.py:216-253). The merge base already blocked on the running flags with .item() each period, so I see no new synchronisation, only more flags per transfer. A warm timing against the merge base would settle it.

"""

arrays: tuple[jax.Array, ...]
arrays: tuple[jax.Array | jax.ShapeDtypeStruct, ...]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed this commit, so here is why. A budgeted simulation of host- or disk-stored finite NNBEGM policies failed at 628c38b with a BeartypeCallHintParamViolation in ReplayPayload.from_policy. The chunk planner receives planning_stand_in descriptors instead of the stored policies (chunk_admission.py:249, host_values.py:541-568), and the finite NNBEGM planning step still insisted on concrete arrays: this annotation, the guard at forward_program_profiles.py:661-666, and the leaf annotation of _abstract_policy_leaf at forward_program_profiles.py:709. Those are type assumptions from #440, from when every stored policy was a device array.

Nothing in planning needs the values. _profile_finite_decision maps every leaf through _abstract_policy_leaf, which keeps shape, dtype, weak type and placement, and hands that to _prepare_program. So the commit admits jax.ShapeDtypeStruct at the three sites, plus a fourth: replay_payload_reads at replay_inputs.py:64 enumerates the leaves a replay acquires, and without counting a descriptor there _policy_read_sources at chunk_profiles.py:1508-1509 zips four stand-in leaves against zero reads. The corridor pins in the candidate certificate are re-anchored in the same commit.

The route is public: a same-instance solve with value_storage="host" or a directory, plus a positive device_memory_bytes, on CPU. The budget suite runs it with device storage only, and the NNBEGM storage tests remove the budget (tests/solution/test_value_storage.py:463-477), so neither suite simulates finite NNBEGM policies from storage under a budget. The DC-EGM policy-first read at forward_program_profiles.py:466-476 goes through the same _abstract_policy_leaf and failed the same way.

53cbc91 adds tests/simulation/test_budgeted_stored_policies.py, which covers both routes: host- and disk-stored panels must equal the device panel exactly. All four cases fail on the sources of 628c38b and pass on 1a2d304. With the fix, tests/simulation/test_finite_policy_budget.py and tests/solution/test_value_storage.py pass as well, 94 cases.

mj023 and others added 5 commits November 5, 2026 18:47
A disk-storage archive now writes its record of the solve into the
`.h5.partial` file when it is created and updates it as each period or
code lands, so `Model.solve` needs no handler and `abandon` is gone.
Completion replaces the record with the manifest. This closes three gaps
the review found: block-major `simulate(solution=None)` left no record, a
cleanup write after a failed write could replace the `ValueStorageError`,
and a failed fsync or rename was replaced by an h5py error. A finished
file whose flush or rename fails is kept as `.incomplete` and loads.

Also from the review: a directory the archive cannot be created in raises
`ValueStorageError`; planning descriptors and disk reads keep weak types;
disk reads refuse implicit x64 narrowing; the writer docs say three
pending periods and that its wait has no timeout.

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

Block-major reads and uploads of stored blocks now go through the guard
the period-major disk reads use, so every off-device read raises
`IncompatibleSolutionError` instead of narrowing float64 values when x64
was turned off after the solve. The guard is shared as `place_stored_leaf`.

The operand-placement test still expected `ReplayPayload` to refuse
`jax.ShapeDtypeStruct` leaves, which 1a2d304 admits for planning from
stored policies; it now checks that descriptors are accepted and other
leaves are still refused. Also fix comments that described the removed
`abandon` step.

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

# Conflicts:
#	tests/candidate_certificate/sources.json
tests/simulation/test_budgeted_stored_policies.py, added in 53cbc91, was
in no CI invocation, so the manifest test failed and no lane ran it. The
generator registers it and rebalances the shards around it. It has no
measured weight yet; the shards run it at their median weight, and the
next commit's ci-workload-weights hook records this run's measurement.

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

hmgaudecker commented Nov 6, 2026 •

Copy link
Copy Markdown
Member

@mj023 We hit this refusal while running the EKL 2019 model (lcm-zoo) at production scale. The message itself is good, but it arrives about 4.5 hours too late.

Setup. pylcm 7d0ec63, the #639 stack, which already carries this refusal text. Source5 grid, float64, one A100, device_memory_bytes=51_539_607_552, default value_storage="device".

What happened. Every kernel compiled. Then public_solve ended status=error without solving a single period. Nearly all of the time went into four compilation_waves, with workspace narrowings in between:

run query_cell waves (s) public_solve until refusal
A 64 8461, 3827, 2813, 2700 17844 s
B 32 8282, 2820, 2625, 2281 16049 s

The refusal at single_f_school period 1 was 74,361,084,696 bytes: a 37,282,115,852-byte compiler reservation plus 37,078,968,844 resident value bytes, against a 51,539,607,552-byte budget.

Suggestions for this PR:

  1. Refuse before compiling. Under "device", the resident bytes of stored values are known before any compile. When they alone exceed the budget, or exceed it together with the smallest compiler reservation already seen, stop right after planning (or after the first wave) instead of narrowing through further waves.
  2. Say whether "host" would fit. The planner knows the host-storage resident window, so the message can say whether switching to host storage makes the plan fit, not only suggest trying it. For example: "Resident solved values reach 37.1 GB on device 0 by single_f_school period 1; with the 37.3 GB compiler reservation this exceeds the 48.0 GiB budget. value_storage='host' keeps at most 3 periods resident; with it, this plan fits." (only when that is true; we have not verified it for this model yet)

A rerun of the same solve with value_storage="host" is queued. I'll add its outcome here, since it also exercises this PR's storage path on a large model.

@hmgaudecker

Copy link
Copy Markdown
Member

Follow-up on the rerun I mentioned above. Same pin (7d0ec63), same EKL source5 solve on one A100, width 64, with ExecutionConfig(value_storage="host").

Outcome: refused at the same cell after a similar compile time.

  • _CellBudgetError at single_f_school period 1, core _chunks.
  • The smallest total is 74,361,459,472 bytes: a 37,282,490,628-byte compiler reservation plus 37,078,968,844 resident bytes. The resident figure is identical to the value_storage="device" run.
  • public_solve ended with an error after 18,078.7 s, almost all of it in four compilation_waves. No period was solved.

The resident split the engine logs explains why host storage has no effect here. At single_m_school period 1, the busiest device holds:

  • 25,194,240,000 bytes of regime_value:married_school. That is exactly 12 periods of 2,099,520,000 bytes, and it shrinks by one period at each later school period.
  • 6,561,000,000 bytes of married_postfertile values and 5,248,800,000 bytes of married_fertile values.

My reading of the code at 7d0ec63 follows. I have not instrumented it.

  • Under host storage, _build_planned_input_liveness keeps a regime value live only through its own period and the next one.
  • The ledger never makes pinned_artifacts eligible for release.
  • undeclared_read_pins pins the same-period reference values and the next-period values of any dispatch with a program that declares no value reads.
  • So if one of EKL's school cores declares no reads, every married period it can reach stays pinned for the whole solve, under either storage mode.

Questions:

  1. Should a pin from an undeclared read end after the reading period under value_storage="host", the same way declared reads do?
  2. If that is not safe, could the refusal name the pinned owners and the program that caused the pin? At the moment it suggests value_storage="host", which cannot help when the resident bytes are pins.

The message-side requests are collected in #643.

mj023 and others added 2 commits November 6, 2026 15:42
A core that declares no reads pins every next-period value and
continuation leaf it can reach. Under host value storage the residency
walk kept those pins for the rest of the solve, so the planner charged
every later period's values as resident although the solve frees them
with the window; in EKL 2019 this was the whole 37 GB refusal. The host
ledger now counts such a read as a read by its own dispatch, ending with
that period as declared reads do. The device ledger, which release and
donation use, keeps its pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A refusal over stored values arrived only after every compilation wave
and narrowing; EKL 2019 spent 4.5 hours before it. Three checks end it
sooner:

- A cell whose floor, the resident bytes no narrower workspace lowers,
  reaches the budget is refused before anything is compiled.
- Under device storage a refused cell is placed in the host-storage plan
  too. When its compiled candidates fit there but not on the device, the
  solve is refused after that pass, naming the host total and saying to
  switch storage; otherwise the refusal says host storage does not help.
- After a narrowing, the part of the reservation that does not shrink
  with the chunk is estimated; when it does not fit beside the floor,
  the cell is refused instead of narrowed again.

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.

2 participants