…#983)
`recalc_incremental`'s volatile-cell seeding step re-derived every formula
cell's volatility from scratch on every call, allocating an uppercase copy
of each formula string and substring-scanning it against
`Registry::VOLATILE_FUNCTIONS` — O(total formula cells) work paid on every
edit, however small the actual dirty closure. Measured at 100,000 formula
cells with a dirty closure of exactly 1 cell: 367ms per edit.
`is_volatile`'s result cannot change without the formula text changing,
which already invalidates `CachedGraph`, so this adds a `volatile:
BTreeSet<CellRef>` field computed once, in the same pass that already
computes `order`/`cycle` when the graph is built. The incremental seeding
step now reads that cached set directly instead of re-scanning
`formula_cells()`. `is_volatile` itself is unchanged — it now has exactly
one caller (graph build) instead of one per incremental recalc.
Isolated, same-process timing of the seeding step alone (immune to
clone/allocation noise elsewhere in a full recalc):
formula cells old (full rescan) new (cached read)
1,000 315.7 µs/call 6 ns/call
10,000 3.64 ms/call 4 ns/call
100,000 47.3 ms/call 10 ns/call
A full `incremental_recalc/row_totals_volatile_seed` benchmark (new, added
to workbook_perf.rs) reproduces the issue's own measurement shape end to
end; on this machine (best-of-1, some run-to-run variance from shared
load):
n before after
100 505 µs 272 µs
10,000 38.0 ms 27.3 ms
100,000 603 ms 369 ms
The full-call numbers move less than the isolated seeding cost because the
remaining time is dominated by other O(n) per-recalc work (workbook clone,
pre-edit snapshot, spill seeding) that this change does not touch.
New test file `recalc_volatile_cache_tests.rs` covers cache correctness
across a graph rebuild: adding a volatile formula, removing one, and
flipping an existing formula's volatility both directions, each asserting
the resulting dirty-closure size via `recalc_incremental_measured` rather
than any internal flag. `recalc_incremental_property_tests` and
`recalc_volatile_frontier_tests` re-verified explicitly and pass unchanged,
confirming this is a pure caching optimization with no semantic change.
`crates/workbook/benches/baselines.json` is intentionally left unchanged:
this dev machine had confirmed CPU contention from another concurrent
benchmark process during this session, which makes it unsafe to bake in
recorded numbers (the regression gate's own docs warn against recording
from an unconfirmed/noisy run). A maintainer should run
cargo bench -p truecalc-workbook --bench workbook_perf -- \
--output-format bencher | python3 .github/scripts/check_perf_regression.py --record
on a quiet machine (confirming the result reproduces on a second run, per
that script's own guidance) before merging, both to add an entry for the
new benchmark and because several existing incremental_recalc entries may
now cross the gate's 40% "unexpected improvement" threshold.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WfhRbND7tjFJ4JeGZ9gHL5
closes #983
Problem
recalc_incremental's seeding step re-derived every formula cell's volatility (Workbook::is_volatile, scanning the formula text againstVOLATILE_FUNCTIONS) from scratch on every incremental recalc call — anO(formula cells)pass paid regardless of how small the edit was, alongside the two otherO(formula cells)passes the same call already pays (snapshot_formula_values, spill-occupancy seeding).Fix
A formula cell's volatility cannot change without its formula text changing, and any formula-text write already invalidates the dependency-graph cache (
sheets_mut/names_mut/etc.). So the volatile set is exactly as cacheable as the graph itself:CachedGraphnow carries avolatile: BTreeSet<CellRef>computed once, at graph-build time, alongsideorder/cycle.recalc_incremental's seeding step reads that cached set instead of re-scanning every formula cell's text.This makes that one term
O(1)per incremental recalc (amortized across cache-warm calls); the two otherO(formula cells)passes are untouched and still scale linearly — see the benchmark comment for why the recorded numbers below aren't flat.Correctness
Three new behavioral tests in
crates/workbook/tests/recalc_volatile_cache_tests.rs, asserting throughWorkbook::graph_builds()(proves whether a rebuild actually happened) and the returned dirty-closure size (exactly what a stale entry would get wrong), not through the private field itself:Benchmarks
New group
incremental_recalc/row_totals_volatile_seed(a formula-heavy sheet with zero actually-volatile functions, timing one unrelated literal write) at n = 100 / 10,000 / 100,000. Independently re-measured myself, before vs. after, on the same machine, same binary otherwise identical except for this change (pre-fix baseline built from a worktree at the parent commit with only the new bench file copied over):The win is a constant-factor reduction, not an asymptotic one — total time is still
O(formula cells)at this n because the other two seeding passes are untouched — matching what the in-file benchmark comment already says.baselines.jsonre-recorded wholesale for issue #983 (see itsnotefield for methodology: best of 2 quiet runs, machine/rustc versions, and why 2 rather than the file's usual 3/5).Verification performed independently
cargo fmtscoped to the touched files: clean (a pre-existing, unrelated rustfmt-version drift exists incrates/coreonmainitself — confirmed there too, not introduced here).cargo clippy --workspace -- -D warnings: clean.cargo test -p truecalc-workbook: 468 passed (54 suites), including the 3 new tests.cargo test --workspace --exclude truecalc-python: 4173 passed (114 suites). (truecalc-pythonfails to link on this machine — missing systempython3.9— confirmed pre-existing onmaintoo, unrelated to this change.)Rebased onto latest
main(which had moved: v9.1.0 release + the AxisMove primitive) before verification; rebase was clean.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Update: rebased onto
mainafter #984 (spill-anchor caching) merged —both touched
recalc_incremental's seeding pass andbenches/ workbook_perf.rs/baselines.json. The code merge was clean (non-overlappingedits in
recalc.rs); the two new benchmark groups inworkbook_perf.rswere kept side by side (a duplicate
group.finish()omission from themerge was also fixed).
baselines.jsonwas NOT hand-merged — a fresh,single, internally-consistent baseline was recorded from one real
cargo benchrun of the fully-combined code (both #983 and #984's fixespresent together), so every entry comes from the same run rather than
splicing two different machines/times.
The remaining
O(formula cells)floor this PR's own benchmark commentsalready document (
snapshot_formula_values,seed_spill_sensitive) is nowtracked as its own issue: #991.