Skip to main content

parser_guard_bench/
parser_guard_bench.rs

1//! Release parser+drop throughput; preparation and semantic checks are untimed.
2//! Each invocation emits CSV; run independent processes, keep every sample.
3#![expect(
4    clippy::unwrap_used,
5    clippy::panic,
6    clippy::indexing_slicing,
7    clippy::arithmetic_side_effects,
8    reason = "benchmark harness: bad arguments or a parse failure abort the run"
9)]
10
11use formualizer_parse::{ASTNode, parse, parser::BatchParser};
12use std::{hint::black_box, time::Instant};
13
14// The old baseline builder has no capacity method; its cache is unbounded.
15// An inherent method in the guarded builder takes precedence over this fallback.
16// This lets the identical harness isolate cache policy via an untimed override.
17#[expect(
18    dead_code,
19    reason = "fallback for the baseline builder; the guarded builder's inherent method shadows it"
20)]
21trait BaselineCacheCapacity: Sized {
22    fn cache_capacity(self, _entries: usize, _bytes: usize) -> Self {
23        self
24    }
25}
26impl<T> BaselineCacheCapacity for T {}
27
28struct Case {
29    name: &'static str,
30    formulas: Vec<String>,
31    iterations: usize,
32    batch: bool,
33}
34
35fn corpus() -> Vec<Case> {
36    let distinct = |f: fn(usize) -> String| (1..=1024).map(f).collect();
37    let mut nested = "A1".to_owned();
38    for n in 1..=12 {
39        nested = format!("IF(B{n}>0,{nested},0)");
40    }
41    let wide = format!(
42        "=SUM({})",
43        (1..=64)
44            .map(|i| format!("A{i}"))
45            .collect::<Vec<_>>()
46            .join(",")
47    );
48    let array = format!(
49        "={{{}}}",
50        (0..8)
51            .map(|r| (0..8)
52                .map(|c| (r * 8 + c).to_string())
53                .collect::<Vec<_>>()
54                .join(","))
55            .collect::<Vec<_>>()
56            .join(";")
57    );
58    let chain = format!(
59        "={}",
60        (1..=128)
61            .map(|i| format!("A{i}"))
62            .collect::<Vec<_>>()
63            .join("+")
64    );
65    vec![
66        Case {
67            name: "short_distinct",
68            formulas: distinct(|r| format!("=A{r}+B{r}*$C$1")),
69            iterations: 300_000,
70            batch: false,
71        },
72        Case {
73            name: "finance_arithmetic",
74            formulas: distinct(|r| format!("=($B$2*C{r}+D{r})*(1+$E$1)^$F$1")),
75            iterations: 200_000,
76            batch: false,
77        },
78        Case {
79            name: "lookup_aggregate",
80            formulas: distinct(|r| {
81                format!(
82                    "=IFERROR(INDEX(Data!$D$2:$D$1000,MATCH(A{r},Data!$A$2:$A$1000,0)),SUMIFS($E$2:$E$1000,$B$2:$B$1000,B{r}))"
83                )
84            }),
85            iterations: 75_000,
86            batch: false,
87        },
88        Case {
89            name: "structured_quoted_unicode",
90            formulas: vec![
91                "=SUM(Table1[[#Data],[Amount]])+'Jan 24:Mar 24'!B5".into(),
92                "=IF(Table1[@[Amount]]>0,'Données été'!$A$1,0)".into(),
93            ],
94            iterations: 100_000,
95            batch: false,
96        },
97        Case {
98            name: "nested_if_12",
99            formulas: vec![format!("={nested}")],
100            iterations: 30_000,
101            batch: false,
102        },
103        Case {
104            name: "wide_sum_64",
105            formulas: vec![wide],
106            iterations: 30_000,
107            batch: false,
108        },
109        Case {
110            name: "array_8x8",
111            formulas: vec![array],
112            iterations: 30_000,
113            batch: false,
114        },
115        Case {
116            name: "left_chain_128",
117            formulas: vec![chain],
118            iterations: 10_000,
119            batch: false,
120        },
121        Case {
122            name: "powers_parenthesized",
123            formulas: vec!["=2^3^2+2^(3^2)+(-2)^2".into()],
124            iterations: 200_000,
125            batch: false,
126        },
127        Case {
128            name: "batch_repeat_64",
129            formulas: (1..=64).map(|r| format!("=A{r}+B{r}*$C$1")).collect(),
130            iterations: 300_000,
131            batch: true,
132        },
133        Case {
134            name: "batch_workingset_4096",
135            formulas: (1..=4096).map(|r| format!("=A{r}+B{r}*$C$1")).collect(),
136            iterations: 300_000,
137            batch: true,
138        },
139        Case {
140            name: "batch_unique_stream",
141            formulas: (1..=50_000).map(|r| format!("=A{r}+B{r}*$C$1")).collect(),
142            iterations: 50_000,
143            batch: true,
144        },
145    ]
146}
147
148fn semantic_digest(formulas: &[String]) -> u64 {
149    // Outside timing; include every source, not only a first representative.
150    let mut h = 0xcbf2_9ce4_8422_2325_u64;
151    for formula in formulas {
152        let ast = parse(formula).unwrap_or_else(|e| panic!("{formula}: {e}"));
153        h ^= ast.fingerprint();
154        h = h.wrapping_mul(0x100_0000_01b3);
155    }
156    h
157}
158
159fn consume(ast: ASTNode) {
160    black_box(&ast);
161    drop(ast);
162}
163
164fn main() {
165    println!("workload,iterations,sources,elapsed_ns,ns_per_parse,digest");
166    for c in corpus() {
167        let digest = semantic_digest(&c.formulas);
168        let builder = BatchParser::builder();
169        let builder = if let Ok(entries) = std::env::var("BENCH_CACHE_ENTRIES") {
170            builder.cache_capacity(entries.parse().unwrap(), usize::MAX)
171        } else {
172            builder
173        };
174        let mut batch = builder.build();
175        // Warm allocator, code and the full working set. Pre-existing token
176        // cache warmup is retained intentionally to reveal eviction thrashing.
177        let unique = c.name == "batch_unique_stream";
178        let warm_count = if unique {
179            4096
180        } else {
181            c.formulas.len().max(4096)
182        };
183        for i in 0..warm_count {
184            let other = unique.then(|| format!("=X{i}+Y{i}*$C$1"));
185            let f = other
186                .as_deref()
187                .unwrap_or(&c.formulas[i % c.formulas.len()]);
188            consume(if c.batch {
189                batch.parse(f).unwrap()
190            } else {
191                parse(f).unwrap()
192            });
193        }
194        let start = Instant::now();
195        for i in 0..c.iterations {
196            let f = black_box(&c.formulas[i % c.formulas.len()]);
197            consume(if c.batch {
198                batch.parse(f).unwrap()
199            } else {
200                parse(f).unwrap()
201            });
202        }
203        let elapsed = start.elapsed().as_nanos();
204        println!(
205            "{},{},{},{},{:.3},{:016x}",
206            c.name,
207            c.iterations,
208            c.formulas.len(),
209            elapsed,
210            elapsed as f64 / c.iterations as f64,
211            digest
212        );
213    }
214}