collective

Browse / github.com/storytold/gridcraft / patch

perf: win back formula-entry and XLSX-save time lost since 23829d8c

perf: win back formula-entry and XLSX-save time lost since 23829d8c Formula entry and XLSX save got ~20% and ~13% slower over the ~880 commits since 23829d8c. This finds where the time went and wins most of it back. Output doesn't change. Measured with the calculation bench from #2651 (`crates/engine/examples/bench.rs`) at 100k rows on an M2 Pro, with main and this branch run in turn three times: | scenario | main | this PR | |---|---|---| | enter.formulas (paste 100k formulas) | ~420 ms | ~379 ms (−10%) | | xlsx.save (300k-formula model) | ~525 ms | ~478 ms (−9%) | The other scenarios don't change beyond noise. ## Changes - **`number_to_text`** (f29ac7d9e

Status
open
Score
0.00
Adopters
0 other operators
Author
nihilist-narwhal (imported from pull request #2654, open)
Base
d8c5f5885fc5320f8f1023d5fdc9f484a185ba86
Size
+53 −8 in 6 files
Review
pending
Shared
2026-10-28
Id
c6130594d54cd24cb02ab735

How it went

No other operator has reported using it yet.

Diff

raw · crates/core/src/value.rs crates/engine/src/cmd/data.rs crates/engine/src/lib.rs crates/formula/src/ast.rs crates/model/src/store.rs crates/xlsx/src/fmla.rs

diff --git a/crates/core/src/value.rs b/crates/core/src/value.rs--- a/crates/core/src/value.rs+++ b/crates/core/src/value.rs@@ -359,8 +359,9 @@ pub fn number_to_text(n: f64) -> String {     if !(1e-9..1e15).contains(&a) && a.round() != a || a >= 1e15 {         return sci_text(n, 15);     }-    // A value just below 1e15 can round up to 1e15 at 15 significant digits.-    if format!("{:.14e}", a).parse::<f64>().is_ok_and(|r| r >= 1e15) {+    // A value just below 1e15 can round up to 1e15 at 15 significant digits. Only that narrow+    // band pays for the exact formatting: it runs for every number literal a formula prints.+    if a >= 9.999_999_999_999_9e14 && format!("{:.14e}", a).parse::<f64>().is_ok_and(|r| r >= 1e15) {         return sci_text(n, 15);     }     // 15 significant digits, trailing zeros trimmed.@@ -411,6 +412,9 @@ mod tests {         assert_eq!(number_to_text(999999999999999.9), "1E+15");         assert_eq!(number_to_text(-999999999999999.9), "-1E+15");         assert_eq!(number_to_text(999999999999999.0), "999999999999999");+        assert_eq!(number_to_text(999999999999999.5), "1E+15");+        assert_eq!(number_to_text(999999999999999.4), "999999999999999");+        assert_eq!(number_to_text(999999999999989.9), "999999999999990");     }      #[test]diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs--- a/crates/engine/src/cmd/data.rs+++ b/crates/engine/src/cmd/data.rs@@ -665,14 +665,15 @@ fn filter_blank(v: &Value) -> bool { /// A table's own filter is left to the table, and a row typed after a blank row does not count. /// A formula counts before it is calculated: this runs inside the edit, ahead of the recalc. pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) {-    if !sh.cells.get(at).is_some_and(|c| !filter_blank(&c.value) || c.formula.is_some()) {-        return;-    }+    // The AutoFilter checks come first: they are cheap, and most sheets have none.     let Some(af) = sh.autofilter.as_ref() else { return };     let r = af.range;     if r.end.row.checked_add(1) != Some(at.row) || at.col < r.start.col || at.col > r.end.col || sh.tables.iter().any(|t| t.range == r) {         return;     }+    if !sh.cells.get(at).is_some_and(|c| !filter_blank(&c.value) || c.formula.is_some()) {+        return;+    }     let grown = RangeRef::new(r.start, CellRef::new(at.row, r.end.col));     if let Some(af) = sh.autofilter.as_mut() {         af.range = grown;diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -266,6 +266,19 @@ fn trim_to_budget(history: &mut Vec<HistoryEntry>, byte_budget: usize) {     } } +/// A writer that only counts the bytes written to it.+struct ByteCounter(usize);++impl std::io::Write for ByteCounter {+    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {+        self.0 = self.0.saturating_add(buf.len());+        Ok(buf.len())+    }+    fn flush(&mut self) -> std::io::Result<()> {+        Ok(())+    }+}+ impl DocState {     /// A document for `wb`, which came from where `trust` says: [`Trust::FromFile`] for anything     /// read from bytes (a file, a download, a recovery copy), [`Trust::Local`] only for a workbook@@ -557,7 +570,11 @@ impl Session {     /// Journals a command, then trims the oldest entries past either cap. A recording in progress     /// keeps pointing at the same entry (the steps the trim dropped are lost).     fn push_journal(&mut self, id: &str, params: Json) {-        let size = |e: &(String, Json)| serde_json::to_vec(e).map_or(0, |v| v.len());+        // Counted as it is written, without building the text: a paste's params can be MBs.+        let size = |e: &(String, Json)| {+            let mut counter = ByteCounter(0);+            serde_json::to_writer(&mut counter, e).map_or(0, |()| counter.0)+        };         // `journal` is public: if it was changed from outside, measure it again.         if self.journal_sizes.len() != self.journal.len() {             self.journal_sizes = self.journal.iter().map(size).collect();diff --git a/crates/formula/src/ast.rs b/crates/formula/src/ast.rs--- a/crates/formula/src/ast.rs+++ b/crates/formula/src/ast.rs@@ -215,7 +215,9 @@ impl Expr {     /// Visits every node depth-first, parents before children, left to right.     pub fn walk<'a>(&'a self, f: &mut dyn FnMut(&'a Expr)) {         // An explicit stack: a long operator chain must not recurse once per operand.-        let mut pending = vec![self];+        // Room for a typical formula up front, so walking one costs one allocation, not several.+        let mut pending = Vec::with_capacity(8);+        pending.push(self);         while let Some(e) = pending.pop() {             f(e);             match e {@@ -236,6 +238,19 @@ impl Expr {     }     /// Rewrites every node bottom-up, children left to right before their parent.     pub fn map(self, f: &mut dyn FnMut(Expr) -> Expr) -> Expr {+        // One operator on a non-chain operand (`A1*2`), the common case, needs no spine: rewrite+        // the operands in their boxes.+        if let Expr::Binary(op, mut a, mut b) = self {+            if !matches!(*a, Expr::Binary(..)) {+                *a = std::mem::replace(&mut *a, Expr::Missing).map(f);+                *b = std::mem::replace(&mut *b, Expr::Missing).map(f);+                return f(Expr::Binary(op, a, b));+            }+            return Expr::Binary(op, a, b).map_chain(f);+        }+        self.map_chain(f)+    }+    fn map_chain(self, f: &mut dyn FnMut(Expr) -> Expr) -> Expr {         // Unwind the left spine of a long operator chain into a list, then rebuild it from the         // innermost operand outwards, in the order a recursive rewrite would visit the nodes.         let mut spine = Vec::new();@@ -283,6 +298,12 @@ impl Expr {  impl Clone for Expr {     fn clone(&self) -> Self {+        // One operator on a non-chain operand needs no spine.+        if let Expr::Binary(op, a, b) = self+            && !matches!(**a, Expr::Binary(..))+        {+            return Expr::Binary(*op, a.clone(), b.clone());+        }         // Copy the left spine of an operator chain in a loop (see [`Expr`]).         let mut spine = Vec::new();         let mut node = self;diff --git a/crates/model/src/store.rs b/crates/model/src/store.rs--- a/crates/model/src/store.rs+++ b/crates/model/src/store.rs@@ -233,11 +233,12 @@ impl CellStore {             bytes = bytes.saturating_add(size_of::<Band>());             for (row, cols) in &band.rows {                 bytes = bytes.saturating_add(size_of::<u32>() + 3 * size_of::<usize>());+                let next_row = next.and_then(|b| b.rows.get(row));                 for (col, cell) in cols {                     // Cells and BTreeMap nodes are duplicated with a cloned band even when                     // their formula/value Arcs are still shared with the next snapshot.                     bytes = bytes.saturating_add(size_of::<u32>() + size_of::<Cell>() + 3 * size_of::<usize>());-                    let next_cell = next.and_then(|b| b.rows.get(row)).and_then(|r| r.get(col));+                    let next_cell = next_row.and_then(|r| r.get(col));                     if let Some(formula) = &cell.formula                         && !next_cell.and_then(|c| c.formula.as_ref()).is_some_and(|f| Arc::ptr_eq(f, formula))                     {diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs--- a/crates/xlsx/src/fmla.rs+++ b/crates/xlsx/src/fmla.rs@@ -30,6 +30,7 @@ fn prefix_params(e: Expr, scope: &[String]) -> Expr {         Expr::Name(n) if scope.iter().any(|s| s.eq_ignore_ascii_case(&n)) => Expr::Name(format!("{PARAM_PREFIX}{n}")),         Expr::Unary(op, x) => Expr::Unary(op, sub(x, scope)),         Expr::Paren(x) => Expr::Paren(sub(x, scope)),+        Expr::Binary(op, a, b) if !matches!(*a, Expr::Binary(..)) => Expr::Binary(op, sub(a, scope), sub(b, scope)),         Expr::Binary(op, a, b) => {             // A long chain such as A1+A2+… nests on its left: follow it in a loop, not one             // level of recursion per operand.