Range edges: memoized expansion, size-aware union, mutable reverse-edge builder (GH-522) - #523
Conversation
Review: GH-522 range-edge allocation (memoized expansion, size-aware union, mutable reverse-edge builder)Read the full diff plus the surrounding call sites in Overall: strong. Three contained, well-motivated fixes with the determinism reasoning written down where the next reader will need it. The three big things I checked and agree with:
Findings below, roughly by value. 1. The memo key includes anchors, which the expansion ignores — avoidable misses
So Cheap fix, no correctness impact: val cache = scala.collection.mutable.HashMap.empty[(SheetName, ARef, ARef), Set[QualifiedRef]]
(sheet, range) => cache.getOrElseUpdate((sheet, range.start, range.end), expand(sheet, range))2.
|
3d3b62b to
be2058f
Compare
08410d6 to
acaa232
Compare
Review — GH-522 range-edge allocationRead the diff against Worth calling out up front: three local edits, no signature churn outside the file, and both immutable→mutable conversions are fully encapsulated behind a private / 1. The
|
Review — GH-522 range-edge allocationRead the diff against A few things worth a look before merge. 1.
|
…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>
acaa232 to
2dcc3ed
Compare
|
Review — GH-522 range-edge allocation Nicely scoped work. Three independent, contained changes, each with the ordering argument written down next to the code rather than left implicit — that is the right way to land a perf fix in a codebase where I could not execute the suite in this environment (mill invocation is not permitted here), so the notes below are from static review; I am taking the reported green run at face value. 1. The sheet-level twin of the same defect is untouched All three fixes land on the workbook-qualified path. The
That path is not cold:
2. The doc comment claims the insertion sequence is identical "so every per-key set ... iterates exactly as before". The per-key sets, yes — same outer iteration, same That is exactly the regime the condensation comment at I traced the consumers and do not think it is observable today — every one does a keyed 3. Dead wart suppression
4. Thread-safety of the two new mutable accumulators The 5. Memo retention window The cache lives for the whole build, so any expanded range stays reachable until the graph is done. For the single-range case this is free — 6.
private def union(a: Set[QualifiedRef], b: Set[QualifiedRef]): Set[QualifiedRef] =
if a.isEmpty then b
else if b.isEmpty then a
else if a.size < b.size && b.size > 4 then b ++ a
else a ++ bOrder-safe by the same argument already given (identity on either side), and it also removes the 7. Test coverage This is the gap I would most want closed before merge. Three changes justified entirely by (a) an ordering invariant and (b) an allocation profile, and neither is asserted anywhere — the existing suite passing is evidence, but evidence that will silently stop applying the next time someone touches this code.
Minor
Net: this looks mergeable. Items 1 (sheet-level coverage) and 7 (regression gates) are the ones I would want addressed either here or as a tracked follow-up; 2, 3, and 6 are small and worth folding in now. 🤖 Generated with Claude Code |
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>
Fixes #522. Stacked on #521 (base:
fix/518-519-kahn-alloc-sigterm); #520's wave-parallelevaluation stacks on top of this.
The defect
The workbook-level dependency graph materializes a RANGE reference as one
QualifiedRefedgeper covered cell, per referencing formula. The financial-model shape — many formulas each
aggregating the same driver column — multiplies the two: 9,900 formulas ×
SUM($A$1:$A$5000)=~50M edge objects. JFR on one
recalcof that book (on top of #521, so none of this is theKahn quadratic): 135.8 GB total allocation, of which the actual SUM math is 12.3 GB — ~70%
is graph build/traversal churn on duplicated range edges. This is the shape of the deployed
51k-formula lender models (session
sesn_01HdDiU7hyCyL7hqdLhCqYMs).Three contained fixes (no structural change)
memoizedCells— one graph build expands any given (sheet, range) exactly once; everyreferencing formula shares one immutable Set instance. Applied to
fromWorkbookBounded(whole-book recalc + write-verb dirty cone) and
fromWorkbook(recalculateDependents, the-i putpath). Sound because expansion is a pure function of (sheet, range) within onebuild — bounds are computed once up front.
union—extractQualifiedDependenciesmergedSet(oneRef) ++ rangeSet, iterating the5,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 is a function of its contents
alone — so iterating the smaller side into the larger cannot move any downstream order.
Results of ≤4 elements keep the left-to-right build (small sets are insertion-ordered, and
Kahn's emitted order feeds off their iteration).
reverseEdges— the dependents fold allocated a fresh outer-Map node chain per edge, 50Mtimes; replaced by a local mutable accumulator with the identical insertion sequence (so
every per-key set, small insertion-ordered ones included, iterates exactly as before). Shared
by
fromWorkbookBoundedandDependentRecalculation.buildDependentsMap.Measured (JVM assembly, M-series; "heavy book" = 9,900 formulas × SUM over a 5,000-row driver column)
Books whose ranges are small (the 200k-formula chain book) are unchanged (~20 s), as expected —
the fix targets the range-fan-out shape specifically.
What remains on the heavy book is eval-side work (per-evaluation range reads) plus the
O(formulas × range-size) graph traversals (Tarjan/Kahn/cone). Range-compressed edges — a
range NODE in the graph, O(formulas + ranges) — stay the structural end-state, tracked in #522.
Verification
Full evaluator suite green (the ordering-pinned gates — GH-491 twin exactness, GH-492
condensation determinism — are the point: every one of these changes was constrained to keep
iteration orders bit-identical). Full
__.testrun green on the stack tip.🤖 Generated with Claude Code