collective

Browse / github.com/storytold/gridcraft / patch

perf(calc): find formulas a cleared cell may unblock from a set of #SPILL! cells

Perf: clearing one cell scans every formula in the workbook, and each changed cell scans a 64x64 window Found in a speed audit of `crates/calc` (upstream `main` at d8c5f588). Results are correct; only time is affected. **Cause.** In `Calc::cells_changed` (`recalc.rs`), when a changed cell is now empty, the "an emptied cell can unblock an anchor" step walks every key of `graph.nodes` and looks up each one's cached value (`sh.value(c) == #SPILL!`). The same loop runs in `run` for every area a formula stopped spilling over. In addition each changed cell scans a 64 x 64 window of stored cells for `#SPILL!` values, one tree descent per row. **Measured** (release build, one pinned core): 100,000 for

Status
open
Score
0.00
Adopters
0 other operators
Author
vangel76 (imported from pull request #2696, open)
Base
89ac453efa7ea03a3ab0b0d24fd85ca51172f5cc
Size
+167 −24 in 2 files
Review
pending
Shared
2026-10-28
Id
a41e9c84e792cfa7cc7a06a8

How it went

No other operator has reported using it yet.

Diff

raw · crates/calc/src/recalc.rs crates/calc/src/tests.rs

diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs--- a/crates/calc/src/recalc.rs+++ b/crates/calc/src/recalc.rs@@ -10,7 +10,7 @@ //! and the formulas that read one are evaluated again until the loop settles //! (`PassHost::iterate_loops`). -use std::collections::{BTreeSet, VecDeque};+use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::hash::{BuildHasherDefault, Hasher};  use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};@@ -38,11 +38,61 @@ pub struct Calc {     /// Cells whose formulas have errors from cycles.     pub circular: Vec<Key>,     pub last_recalc_cells: usize,+    /// The cells whose value is `#SPILL!`, kept so that finding the formulas an emptied cell or a+    /// freed spill area may unblock doesn't walk every formula or window of cells. Filled in+    /// `rebuild`, kept up to date by `cells_changed` and by the write-back of a run; an entry is+    /// always checked against the workbook before it is used.+    spill_errors: SpillErrors,+}++/// A set of cells by row, each row's columns in their own set: a window of rows and columns is+/// found by one column range per row that has an entry, so the cost follows the cells in the+/// window, not those to the side of it in the same rows.+#[derive(Clone, Debug, Default)]+struct SpillErrors {+    rows: BTreeMap<(usize, u32), BTreeSet<u32>>,+}++impl SpillErrors {+    fn is_empty(&self) -> bool {+        self.rows.is_empty()+    }++    fn clear(&mut self) {+        self.rows.clear();+    }++    fn insert(&mut self, (sheet, c): Key) {+        self.rows.entry((sheet, c.row)).or_default().insert(c.col);+    }++    fn remove(&mut self, (sheet, c): &Key) {+        if let Some(cols) = self.rows.get_mut(&(*sheet, c.row)) {+            cols.remove(&c.col);+            if cols.is_empty() {+                self.rows.remove(&(*sheet, c.row));+            }+        }+    }++    /// The cells of `sheet` in `rows` x `cols` (both inclusive), in row-major order.+    fn range(&self, sheet: usize, rows: (u32, u32), cols: (u32, u32)) -> impl Iterator<Item = CellRef> + '_ {+        self.rows+            .range((sheet, rows.0)..=(sheet, rows.1))+            .flat_map(move |(&(_, row), set)| set.range(cols.0..=cols.1).map(move |&col| CellRef::new(row, col)))+    } }  impl Default for Calc {     fn default() -> Self {-        Calc { graph: Graph::default(), rng: 0x9E37_79B9_7F4A_7C15, fixed_now: None, circular: vec![], last_recalc_cells: 0 }+        Calc {+            graph: Graph::default(),+            rng: 0x9E37_79B9_7F4A_7C15,+            fixed_now: None,+            circular: vec![],+            last_recalc_cells: 0,+            spill_errors: SpillErrors::default(),+        }     } } @@ -126,8 +176,12 @@ impl Calc {     pub fn rebuild(&mut self, wb: &Workbook) {         self.graph = Graph::default();         let mut circ = Vec::new();+        self.spill_errors.clear();         for (si, sheet) in wb.sheets.iter().enumerate() {             for (c, cell) in sheet.cells.iter() {+                if cell.value == Value::Error(CellError::Spill) {+                    self.spill_errors.insert((si, c));+                }                 if let Some(f) = &cell.formula                     && let Some(e) = f.expr()                 {@@ -185,6 +239,20 @@ impl Calc {         out     } +    /// The cells of `sheet` in `rows` x `cols` (inclusive, `cols` a lower and an upper column) that+    /// show `#SPILL!`, in row-major order.+    fn spill_errors_in(&self, wb: &Workbook, sheet: usize, rows: (u32, u32), cols: (u32, u32)) -> Vec<CellRef> {+        let Some(sh) = wb.sheet(sheet) else { return Vec::new() };+        self.spill_errors.range(sheet, rows, cols).filter(|c| sh.cells.get(*c).is_some_and(|x| x.value == Value::Error(CellError::Spill))).collect()+    }++    /// The formulas showing `#SPILL!` on `sheet` at or above and to the left of `end`: what a+    /// cell emptied or a spill area freed there may unblock.+    fn unblock_candidates(&self, wb: &Workbook, sheet: usize, end: CellRef) -> Vec<Key> {+        let cells = self.spill_errors_in(wb, sheet, (0, end.row), (0, end.col));+        cells.into_iter().filter(|c| self.graph.nodes.contains_key(&(sheet, *c))).map(|c| (sheet, c)).collect()+    }+     /// Recalculates every formula (F9 / Ctrl+Alt+F9, and after loading).     pub fn recalc_all(&mut self, wb: &mut Workbook) {         self.rebuild(wb);@@ -206,6 +274,11 @@ impl Calc {     pub fn cells_changed(&mut self, wb: &mut Workbook, changed: &[Key]) {         let mut cleared: Vec<(usize, RangeRef)> = Vec::new();         for &k in changed {+            if wb.sheet(k.0).and_then(|s| s.cell(k.1)).is_some_and(|c| c.value == Value::Error(CellError::Spill)) {+                self.spill_errors.insert(k);+            } else {+                self.spill_errors.remove(&k);+            }             let formula = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.clone());             match formula.and_then(|f| f.expr()) {                 Some(e) => {@@ -228,6 +301,7 @@ impl Calc {                         && cell.formula.is_some()                     {                         cell.value = Value::Error(CellError::Name);+                        self.spill_errors.remove(&k);                     }                 }             }@@ -250,10 +324,9 @@ impl Calc {                         seeds.push((k.0, *anchor));                     }                 }-                for (c, cell) in sh.cells.iter_range(RangeRef::new(CellRef::new(k.1.row.saturating_sub(64), k.1.col.saturating_sub(64)), k.1)) {-                    if c != k.1 && cell.value == Value::Error(CellError::Spill) {-                        seeds.push((k.0, c));-                    }+                if !self.spill_errors.is_empty() {+                    let (r0, c0) = (k.1.row.saturating_sub(64), k.1.col.saturating_sub(64));+                    seeds.extend(self.spill_errors_in(wb, k.0, (r0, k.1.row), (c0, k.1.col)).into_iter().filter(|c| *c != k.1).map(|c| (k.0, c)));                 }                 if !sh.cells.has(k.1) {                     match emptied.iter_mut().find(|x| x.0 == k.0) {@@ -265,11 +338,7 @@ impl Calc {         }         // An emptied cell can unblock an anchor any distance above or to the left of it.         for (si, end) in emptied {-            for &(s, c) in self.graph.nodes.keys() {-                if s == si && c.row <= end.row && c.col <= end.col && wb.sheet(s).is_some_and(|sh| sh.value(c) == Value::Error(CellError::Spill)) {-                    seeds.push((s, c));-                }-            }+            seeds.extend(self.unblock_candidates(wb, si, end));         }         // Spill areas of changed anchors also change.         for &k in changed {@@ -373,10 +442,15 @@ impl Calc {             let mut freed: Vec<(usize, RangeRef)> = Vec::new();             for (k, v) in results {                 let Some(sheet) = wb.sheet_mut(k.0) else { continue };-                if let Some(cell) = sheet.cells.get_mut(k.1)-                    && cell.value != v-                {-                    cell.value = v;+                if let Some(cell) = sheet.cells.get_mut(k.1) {+                    if v == Value::Error(CellError::Spill) {+                        self.spill_errors.insert(k);+                    } else {+                        self.spill_errors.remove(&k);+                    }+                    if cell.value != v {+                        cell.value = v;+                    }                 }             }             for k in &dirty {@@ -412,6 +486,7 @@ impl Calc {                     if blocked {                         if let Some(cell) = sheet.cells.get_mut(k.1) {                             cell.value = Value::Error(CellError::Spill);+                            self.spill_errors.insert(*k);                         }                         // Not blocked while the pass ran (it would have been #SPILL! there), so                         // blocked by another array of this pass: the formulas that read this one,@@ -457,15 +532,7 @@ impl Calc {             }             // An area a formula no longer spills over may unblock another formula's array.             for (si, o) in freed {-                for &(s, c) in self.graph.nodes.keys() {-                    if s == si-                        && c.row <= o.end.row-                        && c.col <= o.end.col-                        && wb.sheet(s).is_some_and(|sh| sh.value(c) == Value::Error(CellError::Spill))-                    {-                        spill_changes.push((s, c));-                    }-                }+                spill_changes.extend(self.unblock_candidates(wb, si, o.end));             }             if full {                 // Everything was already evaluated once; spill dependents need another pass.diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs--- a/crates/calc/src/tests.rs+++ b/crates/calc/src/tests.rs@@ -2274,3 +2274,79 @@ fn loan_schedule_residue_keeps_excels_sign() {     }     assert_eq!(t.get("P7"), Value::Text("".into())); }++#[test]+fn clearing_a_cell_unblocks_the_arrays_above_and_left_of_it() {+    let mut t = T::new();+    for r in 1..=50 {+        t.set(&format!("D{r}"), &format!("={r}*2"));+    }+    t.set("B2", "7");+    t.set("B1", "=SEQUENCE(3)");+    t.set("C1", "=SEQUENCE(1,3)");+    t.set("A20", "=B1+1");+    assert_eq!(t.get("B1"), Value::Error(CellError::Spill));+    assert_eq!(t.get("A20"), Value::Error(CellError::Spill));+    // Emptied: the array above it spills.+    t.wb.sheet_mut(0).unwrap().cells.remove(c("B2"));+    t.calc.cells_changed(&mut t.wb, &[(0, c("B2"))]);+    assert_eq!(t.num("B3"), 3.0);+    assert_eq!(t.num("A20"), 2.0);+    // Typing over the area blocks it again; far from it, nothing is disturbed.+    t.set("B3", "9");+    assert_eq!(t.get("B1"), Value::Error(CellError::Spill));+    t.set("D50", "1");+    assert_eq!(t.get("B1"), Value::Error(CellError::Spill));+    assert_eq!(t.num("D49"), 98.0);+    // Clearing it again frees the array.+    t.set("B3", "");+    assert_eq!(t.num("B3"), 3.0);+}++/// A sheet with `#SPILL!` cells in rows 0..64, columns 100..16384 (as blocked arrays leave), none+/// near column A.+fn spill_error_field() -> T {+    let mut t = T::new();+    let sh = t.wb.sheet_mut(0).unwrap();+    for r in 0..64 {+        for col in 100..16_384 {+            sh.cells.set(CellRef::new(r, col), Cell::value(Value::Error(CellError::Spill)));+        }+    }+    t.calc.recalc_all(&mut t.wb);+    t+}++#[test]+fn an_edit_far_left_of_many_spill_errors_does_not_disturb_them() {+    let mut t = spill_error_field();+    t.set("A65", "5");+    assert_eq!(t.num("A65"), 5.0);+    assert_eq!(t.get("CW1"), Value::Error(CellError::Spill));+    assert_eq!(t.get("XFD64"), Value::Error(CellError::Spill));+    // A blocked formula inside the window is still found when a cell to its right is cleared.+    t.set("DA66", "7");+    t.set("CZ66", "=SEQUENCE(1,3)");+    assert_eq!(t.get("CZ66"), Value::Error(CellError::Spill));+    t.wb.sheet_mut(0).unwrap().cells.remove(c("DA66"));+    t.calc.cells_changed(&mut t.wb, &[(0, c("DA66"))]);+    assert_eq!(t.num("DA66"), 2.0);+}++/// Probe: single-cell edits in column A below a block of `#SPILL!` cells to its right.+/// `cargo test -p gridcraft-calc --release -- --ignored --nocapture spill_error_field_probe`+#[test]+#[ignore]+#[allow(clippy::disallowed_methods)]+fn spill_error_field_probe() {+    let mut t = spill_error_field();+    let mut best = std::time::Duration::MAX;+    for i in 0..9 {+        let start = std::time::Instant::now();+        for _ in 0..100 {+            t.set("A65", &format!("{i}"));+        }+        best = best.min(start.elapsed());+    }+    eprintln!("100 edits of A65 beside 1,040,000 spill errors: best of 9 = {best:?}");+}