Skip to content

eval/optimize/dryrun: nested aux+PAO batching, perf-first objective, dry-run cost-profile backend, rank-general CSV moments#568

Open
evaleev wants to merge 71 commits into
masterfrom
feature/eval-predicted-peak-trace
Open

eval/optimize/dryrun: nested aux+PAO batching, perf-first objective, dry-run cost-profile backend, rank-general CSV moments#568
evaleev wants to merge 71 commits into
masterfrom
feature/eval-predicted-peak-trace

Conversation

@evaleev

@evaleev evaleev commented Jul 1, 2026

Copy link
Copy Markdown
Member

Eval/optimize/dry-run infrastructure for costing, sizing, and batching CSV-CCk / PNO-CCSD factorizations, motivated by the C60 PNO-CCSD OOM (the dense free-μ̃ (g·C)(g·C) giant). This branch grew well past its original "observe-only predicted-footprint hook" scope (that hook has since been removed — see below). Paired MPQC PR: ValeevGroup/mpqc4#783.

What this delivers

1. Nested multi-axis (aux + PAO + occupied) batching in the evaluator

  • EvalExpr.batch_axes_: binarize stamps per-node sliced-sets; the batched evaluator prefers the per-node annotated batch axis.
  • Recursive re-entry on the batched scratch realizes nested multi-axis batching (one batch loop per annotated axis); screening relaxation scales by the product of batch counts.
  • External-occupied (spectator) forest batching: a free/spectator external occupied axis -- carried on the root but contracted at no node (e.g. a PNO/CSV composite's occupied protoindex) -- is sliced by an OUTER forest loop wrapping the whole per-term evaluation, with the aux+PAO intra-term batching nested inside. optimize() recognizes such occupied protoindices as batchable and emits the choice as a forest-level ExternalBatchAxis signal (domain-neutral BatchPolicy config). This lets PNO-CCSD batch over occupieds, not just aux/PAO, to bound the dense free-mu-tilde (g.C)(g.C) giant.
  • OptimizeOptions.term_batch_axes surfaces per-node sliced-sets per summand in RPN order.

2. Threshold-gated batched selection in optimize()

  • PeakBatchedModel / reconstructed_batched_peak oracle prices the batch contribution buffer; peak_threshold (bytes) plumbed through BatchPolicy / CostParams.
  • Threshold-gated selection: min FLOPs s.t. peak ≤ P_max, min-peak fallback. accumulation_factor allowed for m≥2 (nested), gated by the DP==oracle identity. Sliced-axis model target floored to ≥1 to match the runtime.

3. Perf-first DenseTimeSpace{,Batched} single-term objective

  • New objective selecting by FLOPs-then-peak (perf-first, peak-second), so a peak threshold no longer forces a FLOPS-catastrophic-but-sliceable factorization (the C60 4-PAO AO-integral pathology). Design spec + implementation plan included; C60 perf-first vs peak-first comparison test.

4. Dry-run cost-profile eval backend

  • A zero-data Result backend + CostModel that replays a factorized IR and reports memsize + FLOPs + exec cost (a pluggable sizing oracle; dense by default), not just size.
  • CostProfile struct + reusable cost_profile() entry point; [dryrun-perf] / [dryrun-trace] routed through it. Opt-in scratch-fold peak sink for a faithful batched-replay peak; gated cache with batchable-axis veto and per-term reset.
  • SizeRegime: per-space extents + per-rank CSV moment tables (power means). Post-transform (μ̃ + Κ) C60 residual fixtures and DP-inspection mechanics; power-mean sizing contract locked with a regression test.
  • Rank-general CSV moment dispatch: inner_pow dispatches by cluster rank (1→OSV, 2→PNO, ≥3→a csv_moment_by_rank table if present, else the PNO-table fallback), so CSV-CCSDT triples are sized by their own domain. Pairs with the MPQC rank-general CSVSizeMoments.

5. Index-space registry: occupancy predicates robust to non-physical spaces

Enabling the occupied-spectator scan in optimize() against a real CSV/PNO registry surfaced a latent registry bug. is_pure_occupied / is_pure_unoccupied / contains_occupied / contains_unoccupied reduced an index's quantum numbers to physical-particle (spin) attributes and then looked up the vacuum-(un)occupied space via a throwing retrieve, so an index whose reduced qns had no registered occupied space threw instead of answering the occupancy question. Two coordinated fixes:

  • Physical spaces (AO, PAO) are spin-independent, not spin-less. They span real particle states, so they must carry the convention's spin_any in the physical (spin) sector alongside their LCAO trait bit. add_ao_spaces / add_pao_spaces previously registered them with only the trait bit (spin bits empty), so physical_particle_attributes() reported no spin and the occupancy lookup queried a spin flavor with no occupied space. These now take an explicit spin_any parameter (Spin::null for SpinConvention::Legacy, else Spin::any, matching make_*_spaces) and register spin_any | trait.
  • Non-physical auxiliary spaces (density-fitting, batching) legitimately carry no spin; asking whether they are occupied must answer false, not throw. The four predicates now route through non-throwing helpers (vacuum_occupied_type_or_null / complete_type_or_null / vacuum_unoccupied_type_or_null) that return a null type when no space is registered at the reduced qns. Byte-identical for physical inputs.

Unit-test callers of add_ao_spaces / add_pao_spaces were updated to pass the spin_any matching each test's registry convention (Default/None → Spin::any, Legacy → Spin::null).

6. Fixes / misc

  • Fix stack-use-after-scope in tot_inner_rank.
  • Doc: rename the eval backend brand CostToken/DryRun for clarity; reframe the spec as cost-model replay.

Note on history

The original observe-only predicted-footprint (predict) hook on CacheManager was added and then removed within this branch (commits a19dbfb5fbc5bd6cb5); it nets to zero. The dry-run cost-profile backend above is its principled replacement — it predicts the whole factorized IR up front rather than per-op during materialization. This PR's title/body have been updated accordingly.

Validation

[dryrun] / [dryrun-df] / [dryrun-perf] / [optimize] suites pass; new rank-general dispatch regression test passes. The registry fix keeps the full space/mbpt suite green ([elements], [mbpt], [mbpt/cc], index_space, spin, [algorithms] incl. the Legacy-convention PAO canonicalization test) and [optimize] (which exercises the PAO+DF compute_external_batch_axis emission path); MPQC he10-csv-cck-2-pao correlation energy is unchanged vs the pre-fix binary to ~1e-13. Branch base is master.

evaleev added 3 commits July 2, 2026 22:31
…vel >= 2)

Add a predict_hook to CacheManager, mirroring shaped_product_hook but
observe-only: consulted at each binary Product node before materialization
(self-gated on the eval trace level) to emit a "Predict" line naming the op
and its predicted result footprint. This names an intermediate that exhausts
memory even when it dies materializing (the post-hoc Eval line never prints
for such an op).

The hook is propagated into the batched scratch cache; batched products carry
no shaped-product hook, so they are reported as unshaped(batched). The TA
backend factory make_predict_hook() builds the hook from a TAEvalContext,
reusing the shaped hook's result-outer-trange computation and a ToT
average-inner-extent estimate to predict the footprint.
Add Logger::eval.heap_stats (std::function<std::string()>); when set, its
(already rank-reduced) return is appended to each Eval line after rss=, and
omitted entirely when unset. Lets a backend report allocator-level memory
(e.g. glibc all-arena in-use vs system) right at an RSS jump, to separate
live heap from retained-free heap.
Add Logger::eval.release_memory (std::function<void()>) and call it via
log::release_after_op() after each freshly evaluated op in evaluate(),
regardless of trace level. Lets a backend return retained-free heap to the
OS between ops -- e.g. the large per-batch transients freed while building a
batched DF leaf -- so they do not linger as allocator-retained free pages
and inflate RSS before the next big op allocates. Local/non-collective by
contract; empty hook = no-op (default).
@evaleev evaleev force-pushed the feature/eval-predicted-peak-trace branch from 5810a5c to 8e23906 Compare July 3, 2026 02:33
evaleev added 26 commits July 3, 2026 12:43
The eval-time footprint estimate lifted the result's dense outer volume by
an operand's average inner density (size_in_bytes / dense outer volume). That
borrows the operand's fill, so it underestimates any contraction whose result
is denser than its operands -- most severely the free-mu~ giant g(mu~,mu~,K).C,
where the free mu~ spreads from the per-pair domain to the full range (~3x low
in practice). It is also computed from materialized operand sizes, which are
not what the factorizer's analytic cost model uses, so it cannot reflect or
validate the tree it chose. Its one unique feature -- naming an op that OOMs
before its post-hoc Eval line prints -- does not survive the real failure mode
(async SIGBUS / OOM-kill), where the buffered Predict line never flushes.

Remove make_predict_hook and its tot_avg_inner_factor / pred_outer_elems
helpers, predict_hook_type, set_predict_hook / predict_hook(), the eval-time
consultation in evaluate(), and the make_batched_scratch propagation. The
post-hoc Eval line (actual sizes) and the shaped-product hook are unchanged.
Regression guard for the C60 PNO-CCSD OOM. The batched CSV-CCk residual
term

  g{i_3;i_1;K} C{a_1<i_1,i_2>;mu} g{mu;mu;K} C{mu;a_3<i_2,i_3>}

factorizes, under aux(K)-only batchability, into the double-proto
intermediate I(i_1,i_2,i_3,K; a_3<i_2,i_3>, a_1<i_1,i_2>) -- two 2-occ
PNO composites sharing i_2, ~185 GB materialized -- which is what OOMs on
C60 (1800 AOs, 8 ranks, 768 GB node).

The probe pins the mechanism:
  - DenseFLOPs / DensePeakSize pick a g.g-first tree that never forms the
    double-proto (max_imed 1.3e11 elems);
  - DensePeakSizeBatched with only K sliceable picks the double-proto
    (max_imed 1.8e13) -- correctly, since with K the sole batch axis the
    double-proto's K-sliced peak beats the avoiders' unsliceable mu-tilde
    intermediate;
  - making mu-tilde (PAO) batchable TOO flips STO back to the g.g-first
    tree (max_imed 1.3e11) -- the double-proto becomes avoidable.

So the OOM root cause is the too-small batchable-index set (aux only),
not a suboptimal STO or a batching-eval bug. Guards any future change to
the batched peak objective or the batchable-index policy.
…ax, min-peak fallback)

PeakBatchedModel::reconstruct now selects the root-frontier point by
peak_threshold (bytes) instead of the unconditional peak_flops_tolerance
band: among points whose peak*numeric_size fits peak_threshold, pick min
flops (ties by lower peak); if none fit, fall back to min peak. Default
peak_threshold = +infinity makes every point feasible, so min-flops wins,
matching the non-batched schedule unless a finite threshold is set.

peak_flops_tolerance is kept on PeakBatchedModel for source compatibility
but is no longer consulted there; it still gates the unbatched DensePeakSize
model. Doc comments in options.hpp/single_term.hpp updated to reflect the
split.

Adjusted three existing batched tests whose assertions were inherently
peak-driven (not flop-driven) demonstrations, so the default +inf threshold
no longer masks what they exercise:
- "OSV deferral reproducer (tetramer term 3)": the persistent_only-gate
  probe now sets peak_threshold=1.0 to force the min-peak fallback.
- "quadratic bubble: early-K integral vs late-K t.(gC)": choose() and
  real_config_integral() now set peak_threshold=1.0 for the same reason
  (this test is gated behind __OPTIMIZE__ and was verified via a separate
  -O1 recompile of test_optimize.cpp relinked against the Debug libraries).

New test: "threshold gates batching: default (+inf) picks min-flops
regardless of K_b; near-zero threshold falls back to min-peak", reusing the
quadratic-bubble motif to show the divergence directly.
…s.term_batch_axes

Task 3.2 of the PNO-CCSD aux+PAO batching feature: thread the per-node
sliced-Index-sets that PeakBatchedModel::reconstruct_axes (Task 3.1)
computes out of the optimizer, so each optimized residual summand
carries which axes to slice at each of its contraction nodes.

- cost_model.hpp: add run_single_term_opt_axes, a companion to
  run_single_term_opt that calls Model::reconstruct_axes instead of
  reconstruct, returning {EvalSequence, node_axes} with the same nt==1/
  nt==2 shortcuts (empty axes / one empty entry respectively).
- single_term.hpp: thread an optional
  container::vector<container::svector<Index>>* out_axes = nullptr
  through both the detail and public single_term_opt overloads. The
  DensePeakSizeBatched arm calls run_single_term_opt_axes when out_axes
  is set; every other objective asserts out_axes is null. Both
  overloads clear *out_axes on entry so an early return (e.g. prod
  with < 3 factors) never leaves it holding a previous call's data.
- optimize.cpp (opt_pure_product): declare node_axes, pass it through
  only on the DensePeakSizeBatched call path, and after building the
  result record (*opts.term_batch_axes)[result.get()] = node_axes when
  the out-channel is set. optimize_impl/optimize() need no changes:
  OptimizeOptions is threaded by reference/value-copy already, so the
  shared_ptr out-channel survives unchanged.
- options.hpp: add OptimizeOptions::term_batch_axes (a
  shared_ptr<unordered_map<Expr const*, per-node axes>>), default null
  (no behavior change). Added the includes/forward-decl (container.hpp,
  <unordered_map>, <memory>, class Expr) it needs.

Verified the RPN-to-Product alignment: single_term_opt's Product-
building loop (single_term.hpp) iterates the EvalSequence left to
right and, on each -1, pops rexpr (top of stack, most recently pushed)
then lexpr (pushed before it) to form Product{lexpr, rexpr}. Since the
sequence is emitted in the same left-first post-order in both
reconstruct and reconstruct_axes (identical build() recursion:
recurse into the lp_first-selected "first" operand, then the
"second", then append -1), the j-th -1 encountered by the loop is
exactly the j-th entry pushed into reconstruct_axes's node_axes. No
behavior change for existing callers (term_batch_axes defaults to
null); build + [optimize] unit tests pass (339 assertions, 12 cases).
Task 3.3 of the PNO-CCSD aux+PAO batching feature: consume Task 3.2's
OptimizeOptions::term_batch_axes at binarize time.

- eval_expr.hpp: add a private container::svector<Index> batch_axes_{}
  member to EvalExpr with batch_axes()/set_batch_axes() accessors, and
  BinarizationOptions::node_batch_axes (per-contraction-node sliced-sets,
  RPN/post-order, left-first). Default-empty in both cases, so existing
  callers see no behavior change.
- eval_expr.cpp: thread a std::size_t& node_counter through
  impl::binarize / binarize(Sum, ...) / binarize(Product, ...). In
  binarize(Product, ...)'s make_prod lambda, the tensor*tensor branch is
  the genuine DP contraction node (scalar*scalar and scalar*tensor
  branches are scaffolding from bare scalar factors or the trailing
  scalar wrap, never counted by the optimizer): stamp
  opts.node_batch_axes[node_counter] onto it when in range, then always
  advance node_counter. The top-level binarize(ExprPtr, ...) entry point
  owns the counter and, when opts.node_batch_axes is non-empty, asserts
  the final count matches its size -- a mismatch means the optimizer's
  and binarize's post-orders diverged.

Verified the alignment: single_term_opt builds every contraction as a
Flatten::No 2-factor Product, so binarize's fold_left_to_node visits
exactly one tensor*tensor node per level, in the same left-first
post-order reconstruct_axes emitted node_axes in.

- test_optimize.cpp: new [optimize][annotate] test round-trips
  optimize() (DensePeakSizeBatched, forced batching via a tiny
  peak_threshold, an aux Κ index shared between two factors) through
  binarize(), and checks a node got annotated with the aux index that
  was force-batched.

[annotate] (new test) / [optimize] (13 cases, 344 assertions) /
[EvalExpr] (81 assertions) / [EvalNode] (84 assertions) / [export]
(1955 assertions) all pass.
…h counts

Confirms Task 4.2's re-entrant nesting already threads make_scope_guard
unchanged into the reinstalled inner evaluator, and the outer scope_guard
RAII object stays alive across the per-batch evaluate() calls that trigger
that re-entry. So a backend guard that relaxes block-sparse screening scaled
by its own level's batch count composes multiplicatively across nesting
depth for free: net relaxation = product of batch counts over all alive
levels, matching the invariant that a contribution significant over the
full product of batch axes must not be screened away in any individual
per-level batch cell.

Documents this nesting semantics at the make_scope_guard parameter, the
no_scope_guard/make_no_scope_guard default-factory doc, and the scope_guard
RAII site in make_batched_custom_evaluator.

Adds a structural composition test (dense TensorD has no real block-sparse
screening to relax, so numeric validation is deferred to the Phase 6
end-to-end MPQC run): a custom ScopeGuardFactory whose RAII guard records
construction/destruction against a shared "currently alive" stack, driven
over the existing depth-2 nested batching tree. Both guards are alive
simultaneously (stack depth 2) once per outer batch, and every such instance
records outer_batches * inner_batches == 9, proving the multiplicative
composition invariant.
sliced_footprints priced a sliced axis at min(e, batch_target_size(ix))
with no floor, while the TiledArray backend's result materialization
floors the realized slice to max(target,1) (result.hpp:374). A target
of 0 (a common default, e.g. MPQC's aux_target_size) made the DP model
see a zero-byte footprint for that axis and never force a second axis
to also slice, even though the runtime slices to a whole tile anyway.
Floor the model target to >=1 so it matches the runtime in the
degenerate zero-target case.
…sidual

Adds sequant::eval::dryrun::SizeRegime (per-space extents + CSV/OSV power-mean
moment tables) and a dedicated unit-test OBJECT-lib group exercising it against
the real C60 CSV-CCSD residual fixture (mpqc's csv_eqn_Rs.serialized). Confirms
the deserialize path and the DensePeakSizeBatched + term_batch_axes + binarize
round-trip mechanics end-to-end (per-summand optimize(), Product::Flatten::Yes
re-flattening of the deserializer's literal nesting), plus a self-consistency
memsize check.

The fixture is pre-transform (no PAO/DF-aux indices), so this is
infrastructure and mechanics only -- the actual PAO/K batch-axis go/no-go
verdict needs a post-transform fixture and is deferred to a follow-up. See
.superpowers/sdd/task-1-report.md for the full history.
…kend, and runtime replay

Completes the DryRun eval backend across two fronts:

1. The real (post-transform) PAO(mu~)/DF-aux(K) batch-axis go/no-go verdict,
   deferred by Task 1: a [dryrun-df] case that deserializes the real
   post-transform CSV-CCSD doubles residual (fixture
   tests/unit/data/csv_ccsd_doubles_residual_df.txt, dumped from mpqc at the
   exact point handed to sequant::optimize()), runs the batched single-term
   optimizer under a C60-scale regime, and confirms the DP annotates a mu~
   batch axis on the free-mu~ giant intermediate (matching the ~1.2 TB
   cluster-trace anchor almost exactly).

2. The DryRun eval backend itself (Tasks 2-6 of
   doc/dev/plans/2026-07-04-dryrun-eval-backend.md): CostModel wraps the
   optimizer's own memsize_counter/flops_counter/roofline_op_cost verbatim,
   adding only an ExtentOverrides indirection so a Result can report a
   runtime-realized (sliced) size rather than always the regime's nominal
   extent. ResultDryRun/ResultDryRunNested are zero-data Result tokens
   (index set + cost model only, no tensor storage) mirroring
   ResultTensorTAPP's structure; both concrete types share one op
   implementation (DryRunOps) and dispatch flat-vs-nested by content via
   make_dryrun_result, matching how the real engine decides
   tensor-of-tensor-ness. EvalExprDryRun/DryRunLeafEvaluator complete the
   plumbing evaluate() needs.

The [dryrun-eval] harness replays the giant term's DP-annotated,
binarized schedule through the REAL runtime (make_batched_custom_evaluator /
evaluate<Trace::On>, with SEQUANT_EVAL_TRACE defined for this translation
unit so note_working_set() fires through the batched evaluator's nested
per-batch/per-member recursion, not just the outermost interception) against
these zero-data tokens, reusing one BatchPolicy object for both optimize()
and the runtime evaluator. On the current codebase the replay does NOT
reproduce the suspected "K-sliced but mu~-full" bug: the giant's realized
peak stays near 1.5 GB (a >99.9% reduction from its 1.21 TB nominal size),
and the largest single materialized result (1.556 GB) matches the nominal
size divided by the product of the mu~ and K batch counts (18 x 43) to
within tile-rounding, i.e. both axes are genuinely nested-sliced by the
runtime as currently implemented.
Add a design spec for a perf-first / peak-second single-term optimization
objective. The existing DensePeakSize model is peak-first: peak_threshold is a
hard filter over factorizations and FLOPS only breaks ties among survivors.
With PAO (mu-tilde) sliceable this structurally prefers the fully-sliceable but
FLOPS-catastrophic 4-PAO integral over the correct (gC)(gC) PPL, whose PNO-pair
leg has an irreducible peak floor above the threshold. This is the C60
PNO-CCSD OOM root cause.

The fix swaps the primary/secondary keys in the two frontier selectors
(select_root, pareto_best), keeping peak_threshold as the slice-down-to target
rather than the feasibility gate. Slicing is perf-neutral in the roofline model
(cflops computed once on unsliced sizes), so a roofline-perf primary rejects the
4-PAO and leaves slicing to the peak-second key.

Names encode the lexicographic order (Dense{Primary}{Secondary}, Space=peak,
Time=perf): rename DensePeakSize -> DenseSpaceTime (peak-first, kept as a
deprecated alias), add DenseTimeSpace (perf-first, new).
Rename DensePeakSize{,Batched} -> DenseSpaceTime{,Batched} (old names kept as
deprecated aliases sharing the same enum values, so every existing
`== DensePeakSize` guard keeps working) and add the perf-first / peak-second
duals DenseTimeSpace{,Batched}.

The peak-first model treats peak_threshold as a hard filter over
factorizations, so with PAO (mu-tilde) sliceable it structurally prefers the
fully-sliceable but FLOPS-catastrophic 4-PAO integral over the correct (gC)(gC)
particle-ladder. The perf-first model adds a `perf_first` flag to PeakModel /
PeakBatchedModel; when set, the root-frontier selector sorts by (flops, then
peak) instead of (peak, then flops). Because pareto_insert keeps one min-peak
point per distinct flops value, this both picks the cheapest factorization and
takes its fully-sliced realization, and peak_threshold is no longer consulted
as a feasibility gate (it can no longer force the 4-PAO). The Pareto frontier,
relax(), roofline cost, and the pareto_best test helper are unchanged.

single_term_opt routes DenseTimeSpace{,Batched} to the peak models with
perf_first set; optimize() gains dispatch arms for both new values.

Tests: DenseTimeSpaceBatched reaches the flop-optimal (gC)(gC) factorization at
both +inf and a near-zero peak_threshold (unlike peak-first strict, which pays
the ladder), and the enum aliases compare equal.
Add the [dryrun-perf] test plus the post-transform DryRun harness apparatus
([dryrun-probe]/[dryrun-df]/[dryrun-eval]) used to diagnose the C60 PNO-CCSD
OOM. [dryrun-perf] optimizes the real C60 giant term (index 38) under both the
peak-first (DenseSpaceTimeBatched) and perf-first (DenseTimeSpaceBatched)
objectives at the faithful config (occ=120, PNO=42, OSV=310, mu-tilde=1800,
aux=4320, pao_ts=256, aux_ts=72, peak_threshold=40 GB) and asserts the
factorization the DP picks.

Result: peak-first forms the fully-sliceable 4-PAO AO integral (4 free
mu-tilde, sliced to 34 GB, under the 40 GB threshold, so it survives the hard
filter despite catastrophic flops) -- the C60 pathology. Perf-first forms no
4-PAO (max 2 free mu-tilde); its largest giant is the correct (gC)(gC)
intermediate {mu-tilde, a<i,i>, K} at 89 GB, far below the 627-769 GB that
OOM'd the real run and below node memory. The fix is validated in-harness.
Extend [dryrun-perf] to also REPLAY the zero-data schedule (both objectives)
through the real eval loop + real CacheManager and report
cache.working_set_hwmark() alongside the largest single transient (result=) and
its full trace line.

Two findings, now documented in the test:

(1) The outer working_set_hwmark is NOT the whole peak under batching. The
    batched custom evaluator runs each batch against a SEPARATE scratch cache
    (make_batched_scratch, eval.hpp:1386), so the peak-first 4-PAO giant's per-op
    hw= reaches ~38.9 GB INSIDE that scratch while the outer accessor reports
    only ~0.2 GB. The outer accessor tracks cached (cross-batch) residency, not
    batched-inner transients.

(2) The runtime Result sizing is not moment-aware. The perf-first largest
    transient = 358.47 GB = 120^2*42^4*8 (occ^2*PNO^4) EXACTLY = the twin-PNO
    size_in_bytes() artifact; the DP cost model's moment-aware size (~89 GB, via
    inner_pow) is the real number.

Conclusion: the moment-aware DP peak model is the reliable predictor today; the
runtime replay hwmark is not, until scratch hwmarks propagate and the Result
sizing is moment-aware.
evaleev added 30 commits July 6, 2026 10:01
…d sweep

Make the [dryrun-df] C60 harness a controlled experiment for the PNO-CCSD
schedule study:
- df_regime array overload takes the real per-nonnull-cluster power means
  M_1..M_4 (heavy tail) as measured by mpqc's PaoPnoRMP2, instead of the
  constant-domain scalar (M_k = mean for all k, which under-sizes
  multi-composite intermediates).
- env sweeps on [dryrun-df]: SEQUANT_UT_DRYRUN_OBJ (perf=dense_time_space vs
  peak=dense_space_time), SEQUANT_UT_DRYRUN_{PNO,OSV}_M2..M4 (real heavy tail),
  SEQUANT_UT_DRYRUN_PEAK_THR_GB (batching peak budget). All default to the prior
  constant-M1 / peak-first / 40 GB behavior, so absent env vars reproduce the
  old test exactly.
- report per-term and total nominal schedule FLOPS alongside the realized peak,
  so the flops-vs-peak tradeoff between factorizations is visible (the 4-PAO
  fully-sliced integral is memory-cheap but flops-heavy; only this surfaces it).

Used to establish: perf-first (dense_time_space) ignores peak_threshold and
forms the ~1.2 TB free-mu~ giant (C60 OOM); peak-first at a realistic threshold
slices mu~ and fits. Also surfaced a threshold non-monotonicity in the
gated select_root worth a separate look.
… cost)

Add a SEQUANT_SELROOT_DEBUG-gated stderr print in PeakBatchedModel::select_root
reporting the DP's OWN chosen frontier point cost (.flops = the roofline exec
cost, volatile-weighted -- the true objective), its peak, whether any point was
feasible, the frontier size, and the global-min .flops over the whole frontier.

This is the tool for calibrating batch:peak_threshold on real systems: summing
chosen_flops across a residual's terms at several thresholds shows the
threshold-vs-cost curve directly (in the DP's own metric, not a post-hoc proxy).
It is what disambiguated the C60 study -- the chosen cost is monotone in the
threshold, and a too-tight threshold (40 GB) sits ~38x above the global optimum
because every low-cost schedule is peak-infeasible. Zero overhead when the env
var is unset.
…f sweep

The [dryrun-df] sweep reported only nominal contraction flops, which are
batch-independent and can move non-monotonically with the peak threshold.
Add a per-term and overall roofline exec-cost accumulation matching the
DP's actual optimization axis (max(flops, mb*max(traffic, prefac*flops/
sqrt(fastmem/tiles))), mb=200) so the sweep prints the quantity the
optimizer minimizes, not just raw flops.
The batched cost model assumed WORK PARITY: slicing an axis shrinks peak
but never changes flops. That holds for axes a node CARRIES (slicing
partitions the work, total preserved), but not for axes an ancestor slices
that the node's result does NOT carry -- there the batched evaluator
re-executes the node once per tile (across-batch recompute; within-batch
sharing is cached, per eval.hpp "replays the build of every compatible
persistent final"). Work-parity under-costs schedules that recompute
across many sliced axes.

Add PeakBatchedModel::charge_batch_recompute: when set, a node at
ancestor-sliced-set B is charged nbatch(b)=ceil(extent/target) for each b
in B not open in the node. Context now carries per-axis nbatch. Default
false preserves the historical cost model exactly; wired in single_term
via the SEQUANT_CHARGE_RECOMPUTE env var for validation (TODO: promote to
an OptimizeOptions/BatchPolicy field).

Finding on the C60 giant: the competitive schedules slice axes they carry
(partitioning), so the recompute charge is small and does not by itself
redirect selection -- the 4-PAO's flops were already accounted correctly.
The C60 OOM is a peak/feasibility problem (the giant does not fit), fixed
by making mu~ batchable plus a realistic peak threshold, not by this
charge. The charge is a faithfulness fix, kept off by default.
The batch recomputation cost is a real cost of batching, so the cost model
should always reflect it. Flip PeakBatchedModel::charge_batch_recompute to
default true and drop the SEQUANT_CHARGE_RECOMPUTE env gating in
single_term (the flag remains as a programmatic escape hatch for work-
parity comparison). No SeQuant unit-test regressions: [optimize] 365
assertions/14 cases and the tagged [dryrun] unit tests stay green.
test_eval_dryrun.cpp had accreted a Phase-1 investigation layer built
against the PRE-transform residual (csv_ccsd_residual.txt: 4-index g, no DF
aux, generic base-virtual 'a' as a stand-in for mu~) whose stated purpose
was to exercise the DP-inspection plumbing until a post-transform fixture
existed. That fixture (csv_ccsd_doubles_residual_df.txt, real mu~ + K)
exists and the post-transform [dryrun-df]/[dryrun-perf]/[dryrun-eval] cases
now do the real batch-axis verdict, so the pre-transform layer is dead
weight. Remove:
  - TEST 'DP-inspection mechanics on pre-transform residual' (explicitly a
    stand-in, verdict-pending case, ~185 lines);
  - TEST 'residual fixture deserializes' (deserialized the old fixture);
  - TEST 'harness sanity: forced threshold annotates a batch axis'
    (synthetic-network plumbing check, now covered by the real cases);
  - dead helpers split_residual_members / is_stand_in_batchable;
  - the csv_ccsd_residual.txt fixture (no remaining references).
Rename the stand-in c60_regime -> probe_regime (its only remaining users
are the SizeRegime/memsize contract unit tests) and rewrite the stale file
header. Kept: the SizeRegime/CostModel/Result unit tests and every
post-transform batched-schedule case. Fast dryrun groups green (42
assertions/15 cases); TU compiles clean.
…NO legs

Add env knobs to the perf-first-vs-peak-first case so it can answer 'is the
4-PNO PPL integral formed?' for any summand at any budget:
  - SEQUANT_UT_DRYRUN_LIST_TERMS=1 -- print every summand (to_latex) to locate
    the PPL/ladder term (term 38: two PAO-PAO DF g's + four C's + t^{ij}_{a3a4});
  - SEQUANT_UT_DRYRUN_TERM=N      -- select the summand (default 38);
  - SEQUANT_UT_DRYRUN_PEAK_THR_GB -- peak_threshold in GB (default 40, was
    hardcoded) so the real job budget (600) can be reproduced;
  - the PERF_TREE dump now also reports PNO/OSV composite free-leg counts, so a
    4-free-PNO node (the held-whole 4-PNO integral) is unmistakable.
Confirms: at 600 GB peak-first keeps max PNO-per-node = 2 (contracts t early,
never forms the 4-PNO integral), while perf-first forms the {a1 a2 a3 a4} node
(the 4-PNO integral, ~pno^4 per pair). Defaults unchanged; [dryrun-perf] green
(13 assertions).
…lat 42

The perf-first-vs-peak-first case hardwired the SCALAR df_regime(pno=42,
osv=310) -- a flat constant domain (M_k=42 for all k) -- so the 4-PNO PPL
node it reports was sized occ^2*42^4 = 387 GB, an under-count. Add named
constants kC60PnoM/kC60OsvM = the real heavy-tailed power means measured by
mpqc PaoPnoRMP2 (jobs 617609/617653/617809: PNO M_1..M_4 = 48.60/54.34/
59.63/64.35, OSV = 206.48/216.66/226.15/234.51) and feed them via the
FAITHFUL array df_regime overload. The 4-PNO node now sizes occ^2*M_4^4 ~=
2.0 TB (dense occ^2 pairs; ~0.86 TB at the screened ~6300 CC pairs) --
faithfully showing why perf-first OOMs C60. Peak-band assertion updated
1..4 TB; [dryrun-perf] green (13 assertions).
…oments

Three cleanups in one:

1. DRY the problem sizes. Every post-transform test repeated the C60 literals
   (df_regime(1800,4320,120,...)) at its own call site. Introduce a constexpr
   ProblemSize struct (extents + PNO/OSV power means) and one instance
   kC60_pVDZF12, plus a df_regime(ProblemSize) overload; every fixed call site
   is now df_regime(kC60_pVDZF12). A moment re-measurement is a one-line edit.
   Remove the now-unused scalar df_regime(...,double,double) overload.

2. Correct the moments. The values 48.60/54.34/59.63/64.35 (PNO) and 206/234
   (OSV) came from job 617653, which was mis-configured with an aug-cc-pVTZ
   OBS. The true cc-pVDZ-F12 moments (job 617809) are PNO 42.03/46.04/49.77/
   53.15 and OSV 148.25/155.04/161.34/166.86. With these the perf-first 4-PNO
   PPL node measures occ^2*M_4^4 = 954 GB (was mis-stated 2 TB at the wrong
   M_4). All post-transform tests (incl. [dryrun-df] env defaults) now use the
   real moments, not the flat 42/310 stand-in.

3. Rename [dryrun-perf] -> [dryrun-objective]. "perf" only named one of the
   two objectives it compares; the test's point is that the objective function
   DETERMINES the factorization (perf-first forms the 4-PNO integral, peak-first
   does not). Title updated to say so.

All 21 dryrun cases green (120 assertions).
Two overlapping whole-residual harnesses, neither asserting correctness:
[dryrun-trace] replayed the forest through the shared cache and wrote a per-op
trace file; [dryrun-df] prints the per-term mu~/K batch-axis escape verdict.
The trace replay is already covered by the [dryrun][cost_profile] unit tests
and the trace file is a regenerable one-off, so cut [dryrun-trace] entirely.
Keep [dryrun-df]'s per-term verdict (uniquely useful C60 diagnostic, with the
env sweep knobs) but mark it hidden ([.]) since it is a dev sweep, not a
regression test -- select it explicitly with "[dryrun-df]". The standing
tests are now [dryrun-objective] (comparative) + [dryrun-eval] (runtime replay)
+ the SizeRegime/CostModel/Result unit tests + the [peak]/[cost_profile]/[cache]
infra tests. Visible dryrun suite green (120 assertions/21 cases).
Sweeps all 55 C60 residual terms under both objectives and reports the
summed-flops ratio (perf-first/peak-first) and max modelled peak per
objective -- the decision input for whether enabling dense_time_space
(perf-first) at scale is worth it. Test-only; no production change.
Result: ratio 0.142 (perf-first ~7x fewer flops), max peak 49.5 vs 5862 GB.
Replace the code-recursive descent in evaluate(node, le, cache) -- the only
evaluate overload with unbounded recursion (the layout/multi-root overloads
just wrap or loop over it) -- with an explicit std::deque work stack. Depth is
now bounded by the heap, not the C++ call stack, so a deep tree (a Sum or
product chain with many operands) no longer risks a stack overflow in the
traversal. The per-node cache key is EvalExpr's stored O(1) hash, so nothing in
the walk recurses.

The rewrite preserves behavior byte-identically:
 - Checked cache wrapper: a hit returns the phase-applied cached pointer; a miss
   on a mapped node schedules a store once computed (the store_after flag
   replaces the recursive evaluate<..., Unchecked> re-entry).
 - Custom-evaluator interception is consulted when a frame is first visited and
   a non-null result short-circuits the subtree (children never pushed) -- the
   subtree pruning batched eval relies on is unchanged.
 - leaf / Adjoint / Sum / Product dispatch, the shaped-product hook, de-nesting,
   canonicalization-phase multiplication, eager per-op release, and every trace
   log site (MultByPhase, custom-eval, leaf, binary, cache access/store) are
   carried over unchanged.

Verified against the eval oracle ([eval_tapp], [dryrun-eval] incl. the batched
custom-evaluator giant-term replay, [dryrun-leaf/result/nested/costmodel],
[EvalNode], [EvalExpr]): 247 assertions in 16 cases, identical to the
pre-refactor baseline.

Note: this makes the *traversal* stack-safe; binarize/EvalExpr construction and
FullBinaryNode's (unique_ptr-owned) destructor remain code-recursive and would
overflow first on a single very deep tree. Full-pipeline stack-safety for one
arbitrarily deep tree would need those addressed too; in current use the
residual is a vector of shallow summand trees.
…tive

FullBinaryNode owns its children by unique_ptr, so the implicitly-generated
destructor recursed to the tree's depth; deep_copy() and the free function
transform_node() likewise recursed. On a deep tree (a long contraction chain or
a nested Sum) these overflowed the C++ call stack at depth ~thousands, which is
why building/copying/destroying such a tree crashed before evaluation even ran.

Replace all three with explicit-stack iterative implementations:
 - ~FullBinaryNode() dismantles the subtree via a work list, detaching each
   node's children before it is destroyed so every node destruction is O(1).
 - deep_copy() and transform_node() do an iterative post-order build with an
   explicit frame stack (std::deque, so a top-frame reference survives
   push_back). transform_node applies its data map post-order; as a pure map its
   application order is immaterial, and its only callers (typed binarize<T> and
   the TA to_ta_node helper) are order-insensitive.

The tree fold (fold_left_to_node / the range-accumulate ctor) and the tree
visitors (detail::visit, parent-pointer based) were already iterative. size(),
operator==, and digraph/tikz remain recursive but are not on the deep-tree
build/eval/destroy path.

Adds a [FullBinaryNode][stack-safety] test that builds, copies, transform_nodes,
and destroys a depth-200000 tree (all O(N)); pre-refactor this overflowed the
stack. Full FullBinaryNode + eval suites remain green.
Reconcile the predicted-peak / batching / dry-run work with master's merged
#576 (outer-product pruning + build_context skip + fast_flops), #575/#574
(iterative evaluate()/binary_node), and TNv3 high-order aux work.

Conflict resolutions:
- options.hpp: CostParams and OptimizeOptions carry BOTH the branch's
  peak_threshold / term_batch_axes and master's prune_outer_products.
- single_term.hpp: keep the perf-first objective dispatch (perf_first,
  DenseTimeSpace/DenseTimeSpaceBatched, peak_threshold, out_axes) and thread
  master's prune_outer_products into every model.
- cost_model.hpp: PeakModel / PeakBatchedModel keep the branch's perf-first
  members (perf_first, peak_threshold, numeric_size, charge_batch_recompute)
  and gain master's prune_outer_products; build_context bodies auto-merged so
  the pruning skip + fast_flops precompute coexist with the perf-first tables.
- optimize.cpp: CostParams init passes both peak_threshold and
  prune_outer_products.
- eval.hpp: keep the branch's malloc_trim release_after_op() hook at each op.
- test_optimize.cpp: keep both the branch's threshold/perf-first tests and
  master's pruning + fast-flops-parity tests.

The branch's own (superseded) outer-product-pruning commits were dropped before
this merge since #576 supersedes them; the predict-hook add/remove experiment
nets to no hook, matching master.
The forest-over-external-axis nesting test built its external Hadamard
spectator as a bare occupied index shared among three tensor slots (g, h,
p) plus the result. Master's TensorNetworkV3::canonicalize_slots
deliberately rejects a non-auxiliary index shared among >2 tensor slots
(it has no well-defined bra/ket slot type), so eval_node threw once this
branch merged master's newer TNv3.

Switch the spectator to an auxiliary index (x_9): a high-order aux
hyperindex carried into the result IS supported (the same case the
eval_with_tiledarray test exercises), the arrays stay flat so the
intra-term batched evaluator runs on its supported path, and the forest +
nested-batching mechanism under test is axis-agnostic. A second inner-aux
predicate keeps the contracted batch axis (x_1) distinct from the
spectator. Occupied-external recognition and sizing are covered
separately at the DP/cost level.
is_valid recognized Constant, Variable, Tensor, Product and Sum but fell
through to a hard assertion for any other expression type. Power (base^
exponent) is a legitimate expression -- it appears, for example, in CC/EOM
residual expressions as an inverse-denominator factor -- so a consumer that
validates such an expression (e.g. MPQC's process_for_evaluation) aborts in
assertion-enabled builds and, with the assertion elided in release, silently
treats a Power-bearing expression as valid only by luck.

Add a Power case: a Power is valid iff its base is valid (the exponent is a
rational and is always well-formed). Power is atomic, so the base is not
reached by the children loop and is validated explicitly here.
…l spaces

is_pure_occupied/is_pure_unoccupied/contains_occupied/contains_unoccupied
reduced an index's quantum numbers to physical-particle (spin) attributes and
then looked up the vacuum-(un)occupied space at those qns via a throwing
retrieve. For an index whose reduced qns has no registered occupied space this
threw instead of answering the occupancy question. Two cases motivated this:

- Physical spaces (AO, PAO) span real particle states and are spin-independent,
  not spin-less; they must carry the convention's spin_any in the physical
  sector. add_ao_spaces/add_pao_spaces registered them with only their LCAO
  trait bit (spin bits empty), so physical_particle_attributes() reported no
  spin and the occupancy lookup queried a spin flavor with no occupied space.
  Fix: add_ao_spaces/add_pao_spaces now take an explicit spin_any parameter
  (Spin::null for SpinConvention::Legacy, else Spin::any, matching make_*_spaces)
  and register the AO/PAO spaces with spin_any | trait.

- Non-physical auxiliary spaces (density-fitting, batching) legitimately carry
  no spin; asking whether they are occupied must answer false, not throw. The
  four occupancy predicates now route through non-throwing helpers
  (vacuum_occupied_type_or_null / complete_type_or_null /
  vacuum_unoccupied_type_or_null) that return a null type when no space is
  registered at the reduced qns. Byte-identical for physical inputs.

Update unit-test callers of add_ao_spaces/add_pao_spaces to pass the spin_any
matching each test's registry convention.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant