Wave-parallel recalculation foundation + evaluator hardening: aggregate-fold memoization, stack-safe Tarjan, dynamic-name classification (GH-520) - #524
Conversation
|
Review — wave-parallel recalculation (GH-520) I traced the wave partition against
Findings below, most significant first. 1. Error-order parity rests on an undocumented invariant of
But nothing at that call site records that the emitted order is load-bearing for anything beyond topological validity. Its docstring is careful about preserving the exact emitted order versus the pre-GH-518 formulation — exactly the kind of comment a later optimization relaxes. Swap the Two ways to de-risk, either is fine:
2.
3. The 4.
5. The determinism caveats omit the clock The scaladoc lists two caveats (seeded (continued in next comment: CLI purity, test coverage, performance, docs) |
|
(continued from the previous comment) 6. CLI: In Every other advisory in this same function goes through the returned summary ( While you are there: the new 7.
Test coverage The new CLI behavior is untested. This PR thesis is a law, and the repo tests laws with ScalaCheck. property("GH-520: parallel recalculation equals sequential") {
forAll(workbookGen) { wb => assertEquals(wb.recalculateParallel(8), wb.recalculate()) }
}This is the single highest-value addition to the PR, and it would independently guard finding 1 as well. Smaller test notes:
Performance
Docs
Nothing here blocks the concurrency design, which I believe is correct. The items I would want before merge are the dead |
|
Follow-up audit and optimization commit This addresses the original review findings and the additional correctness issues found while reviewing the full #521 → #523 → #524 stack:
The main additional performance work is:
Representative local profiles:
The remaining structural opportunity is still #522's range-node/interval-index end state: distinct rolling ranges can retain O(formulas × unique ranges) candidate scans, and full-column bounds still need snapshot-aware work. Validation:
A final independent audit found no remaining P0/P1 correctness blocker. |
Review: Wave-parallel non-iterative recalculation (GH-520)I read the full I also traced the Findings below, most impactful first. 1.
|
…made topological sort 81% of recalc allocation (GH-518) kahnOrder built its result with `acc :+ node` (a full accumulator copy per node) and rebuilt the pending queue with `rest ++ newlyZero` on every step: O(V²) cons-cell allocation on both recalculation hot paths, since sheet-level topologicalSort (every write verb's recalculateDependents) and qualifiedTopologicalSort (whole-book recalculate) share it. JFR on a 50k-formula workbook: 28.1 GB of the recalc's 34.7 GB total allocation — 81% — in this one loop; on the deployed 8 GB / Serial-GC native binary the heap ballooned to its 8 GB build cap and a 51k-formula financial model took ~6.5 minutes per recalc (and >13 CPU-minutes for one put whose dependent cone was the whole book). Rewritten with local mutable state behind the same pure signature — the posture foreachSccOf already takes: ArrayDeque queue, ListBuffer accumulator, mutable.HashMap in-degrees. Iteration stays over the same collections in the same order (the newly-ready scan still folds over the same filtered dependents Set), so the emitted order is unchanged. The GH-492 qualifiedSccOrder condensation Kahn has its own linear drain and is untouched. Measured on the synthetic Camco-shaped book (50k CHOOSE(Case,…)+SUM chains): wall 8.79 s → 4.31 s, user CPU 14.5 s → 9.6 s, and the process$1 allocation site disappears from the JFR profile. New RecalcPerfSpec gate: a 100k-cell chain must sort inside the standing 30 s budget (pre-fix that is ~5e9 allocations — minutes of GC); order pinned head/last/size. Refs #518 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-recalc process (GH-519) The evaluator is a single non-yielding compute step inside one IO, so fiber cancellation is only observed when the whole computation finishes. Under cats-effect's default shutdownHookTimeout = Duration.Inf, a TERM'd xl kept computing at ~100% CPU for the entire remaining recalc (measured: 52 s of survival on a 200k-formula book; 13 minutes / 12:47 CPU-min in the deployed sandbox before an operator SIGKILL) and then discarded the result before the save — all the CPU spent, nothing delivered, and timeout(1) unable to bound an invocation at all. The shipped native binary behaves identically: GraalVM 25+ installs exit handlers by default. runtimeConfig now sets shutdownHookTimeout = 2.seconds: TERM → cancellation attempt → the runtime halts at the deadline. Measured post-fix: process gone 2.1 s after SIGTERM, conventional signal semantics restored. Torn-output exposure is unchanged from the pre-existing SIGKILL reality — -i writes stay atomic (temp + ATOMIC_MOVE), plain -o writes were always direct. The same config disables the CPU-starvation checker (cpuStarvationCheckInitialDelay = Duration.Inf): xl is a batch compute process, a busy compute pool is its expected steady state, and on small containers the checker's warnings drowned real diagnostics (deployed agents were grepping them out of every log). runtimeConfig is public (not protected) so MainSpec can pin both settings. Refs #519 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…expansion, size-aware union, mutable reverse-edge builder (GH-522) The workbook-level graph materialized a RANGE reference as one QualifiedRef per covered cell PER REFERENCING FORMULA. On the financial-model shape — many formulas aggregating the same driver column — that multiplied into ~50M edge objects (9,900 × SUM($A$1:$A$5000)): 135.8 GB of allocation for one recalc, of which the actual SUM math was 12 GB. Three contained fixes, none structural: - memoizedCells: one graph build expands any given (sheet, range) exactly once; every referencing formula shares the same immutable Set instance. Applied to fromWorkbookBounded (recalc + write-verb dirty cone) and fromWorkbook (recalculateDependents, the -i put path). Sound because expansion is a pure function of (sheet, range) within a build. - union: extractQualifiedDependencies merged Set(oneRef) ++ rangeSet, iterating the 5,000-element side once per operator node above a range ref. Union is commutative and any result above 4 elements is a CHAMP set whose iteration order depends only on its contents, so iterating the smaller side into the larger cannot move any downstream order; results of 4 or fewer elements keep the left-to-right build (small sets are insertion-ordered and Kahn's emitted order feeds off them). - reverseEdges: the dependents fold allocated a fresh outer-Map node chain per edge (50M times); a local mutable accumulator with the identical insertion sequence replaces it, shared by fromWorkbookBounded and DependentRecalculation.buildDependentsMap. Measured (heavy book: 9,900 formulas × SUM over a 5,000-row driver column, JVM assembly, on top of GH-518): wall 47.9 s → 27.9 s, user CPU 102.9 s → 35.2 s, peak RSS 6.0 GB → 1.7 GB. What remains is eval-side work (per-evaluation range reads), which is what GH-520's parallelism can then attack. Full evaluator suite green — the ordering-pinned gates (GH-491 twin exactness, GH-492 condensation determinism) are the point. Range-compressed edges (a range NODE, O(formulas + ranges) instead of O(formulas × range-size)) remain the structural end-state; see GH-522. Refs #522 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
recalculateParallel(n) partitions the single topological order into longest-path depth classes over the pruned graph: if u depends on v then depth(u) > depth(v), so two same-wave cells can have no path between them. Each wave evaluates concurrently against the pre-wave snapshot and folds its results in the wave's sequential order — workbook, evaluated map and error vector come out element-for-element equal to recalculate(), which ParallelRecalcSpec pins on wide grids, deep chains, error-bearing books (error ORDER included), cyclic cores with blocked dependents, INDIRECT buckets, repeated runs, and degenerate parallelism (1 and 10000). Determinism by construction, not by luck: no seeded-Rng variant is offered (a seeded generator draws in evaluation-order sequence, which parallelism would reorder — RAND under this entry point keeps the thread-safe system generator); no iterative variant (cyclic components fixpoint sequentially per GH-492); the dynamic INDIRECT/OFFSET bucket keeps its sequential evaluate-last pass; waves narrower than 16 cells run through the sequential fold so chain-shaped regions pay no thread-handoff tax. The per-cell evaluation body is extracted (evalOne/foldResult) and shared verbatim by both paths so they cannot diverge. CLI: `recalc --parallel N` (validated >= 1; 0 refused). An iterate-declared book keeps the calcPr-honoring sequential path with a stderr NOTE — the divergence from the request is never silent. Measured today (M-series, JVM): ~5-10% on allocation-bound books — the evaluator's allocation rate is the shared bottleneck that caps thread scaling (GC burns a full core even single-threaded). The stacked range-edge allocation fix is what unlocks the headroom; the equivalence gates are the point of this commit. Refs #520 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
08410d6 to
acaa232
Compare
58fe396 to
650b53b
Compare
Review: wave-parallel non-iterative recalculation (GH-520) — part 1/2I read the full diff of both commits ( This is very strong work, and the equivalence argument holds up under scrutiny. I verified the two non-obvious preconditions behind the wave lemma: (a) Other things done right: sharing Findings, roughly by severity. 1. Wave workers get the default thread stack size — a real equivalence hole
Cheap fix: give the factory an explicit stack size, e.g. 2.
|
Review: GH-520 — part 2/2 (lower-severity findings, scope, tests)5.
|
|
Review: wave-parallel non-iterative recalculation (GH-520) — part 1/2 Scope note: The equivalence discipline here is exemplary. Extracting I also traced the Findings 1-4 below are the ones I would want addressed before merge; 5-8 in part 2 are follow-ups. 1. Worker threads get the default stack size — In Suggest 2.
3.
4. Targeted recalc can strip a cache it never recomputes
A modified seed that is a static dependent of a dynamic cell is in |
|
Review (GH-520) — part 2/2: follow-ups, tests, verdict 5. The memo key is 6. Targeted recalculation now pays whole-workbook analysis three times over
7. Deferring the pure work into 8. Smaller items
Tests Strong, and the property tests are well chosen — Process note: Verdict The design is right, the equivalence argument is load-bearing rather than decorative, and I appreciate that the description is honest about Amdahl instead of quoting a headline number. Findings 1-4 before merge; the rest are follow-ups. |
# Conflicts: # xl-cli/src/com/tjclp/xl/cli/Main.scala # xl-evaluator/src/com/tjclp/xl/formula/eval/DependentRecalculation.scala # xl-evaluator/src/com/tjclp/xl/formula/graph/DependencyGraph.scala # xl-evaluator/test/src/com/tjclp/xl/formula/RecalcPerfSpec.scala
CHANGELOG/STATUS/roadmap refreshed for the 2026-08-08 cut: Wave 24 (recalculation & seeding integrity) plus the late additions — the evaluator performance stack (#521/#523/#524, 7-37x) and the two lint corruption classes from this week's Excel-repair field incidents (#527/#530). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
Review: wave-parallel recalculation (GH-520) — part 1/2 Reviewed the full 5-commit stack as it lands on The equivalence argument holds up. I worked through the parts that carry it:
Sharing Two findings I would hold the merge on, then follow-ups in part 2. 1.
while pending.nonEmpty do
val ref = pending.removeHead()
pointDependents.getOrElse(ref, Set.empty).foreach(enqueue)
rangeDependents.getOrElse(ref.sheet, Vector.empty).foreach { entry =>
if entry.range.contains(ref.ref) then entry.dependents.foreach(enqueue)
}Every visited ref rescans all range entries for its sheet, and every matching entry re-enqueues its entire dependent set. On the motivating shape from the PR body — 9,900 formulas over Worse in the drag-down window shape ( Both collapse with a fired-set, and it is exactly equivalent because val fired = scala.collection.mutable.HashSet.empty[Int] // or a per-sheet BitSet
val entries = rangeDependents.getOrElse(ref.sheet, Vector.empty)
var i = 0
while i < entries.length do
if !fired.contains(i) && entries(i).range.contains(ref.ref) then
fired += i
entries(i).dependents.foreach(enqueue)
i += 1Related test gap: the 2.
Otherwise the staging change is a genuine improvement — the |
|
Review: wave-parallel recalculation (GH-520) — part 2/2 3. Read-only commands with
4.
The memo safety argument itself checks out: 5. Advisory goes to stdout, not stderr
Performance
The wave fold is the serial bottleneck, and it is fixable. Memory.
Nits
Test coverage Strong. Gaps I would add:
The honesty of the benchmark table and the explicit "NOT a headline speedup" framing are the right call, and the reasons the ceiling sits where it does are correctly diagnosed. Findings 1 and 2 are the ones I would hold the merge on; the rest are follow-ups. |
Closes #520. Final PR of the perf stack (#521 → #523 → this).
Two commits:
1. Wave-parallel non-iterative recalculation (equivalence-gated)
recalc --parallel N/recalculateParallel(n): cells partition into longest-path depth classes (antichains — no dependency paths within a wave); each wave evaluates on a fixed thread pool, results fold back in sequential order for bit-for-bit parity. Refuses seeded-Rng and iterative modes. Seven equivalence gates inParallelRecalcSpecpin parallel ≡ sequential on wide grids, deep chains, error books (including error ORDER), cyclic cores, INDIRECT buckets, and repeated runs.2. Evaluator hardening + acceleration
SUM/AVERAGE/...over the same range fold the range ONCE per calculation generation; parallel readers single-flight one eligible fold. Generation-keyed so cache writes that leave the aggregated range unchanged reuse snapshots (AggregateMemoSpec, 8 gates).-o/-ioutput commits (a killed process can no longer leave a torn destination).Benchmarks (M-series, JVM assembly; baselines = 0.19.1 release)
SUM($A$1:$A$5000)-Xmx512mHonest
--parallelassessment: ~1.0× on these shapes today — the aggregate memo eliminated the redundant folding that parallelism previously attacked, and the remaining cost is serial graph construction + ordered evaluation. It ships as a correctness-proven foundation that inherits every future serial-cost reduction.Full suite:
./mill __.test→ 1028/1028 SUCCESS (including post-merge with main's lint additions).🤖 Generated with Claude Code