Skip to main content

xei_core/
bench.rs

1//! In-editor self-benchmark (`:bench`).
2//!
3//! Times the hot paths that dominate large-file feel — syntax parsing, fold
4//! rebuild/lookup, the per-row token slice the renderer uses, bulk paste, and
5//! the whole-buffer text join — against a synthetic large source plus the file
6//! you actually have open. Everything runs on throwaway local engine instances
7//! so it never disturbs editor state.
8
9use std::time::Instant;
10
11use crate::app::App;
12use crate::buffer::Buffer;
13use crate::fold::FoldState;
14use crate::syntax::SyntaxEngine;
15
16pub struct BenchResult {
17    pub name: String,
18    pub ms: f64,
19    pub detail: String,
20}
21
22impl BenchResult {
23    fn new(name: &str, ms: f64, detail: String) -> Self {
24        Self { name: name.to_string(), ms, detail }
25    }
26}
27
28pub struct BenchReport {
29    pub results: Vec<BenchResult>,
30    pub total_ms: f64,
31    /// Line count of the synthetic workload the fixed benches ran against.
32    pub synthetic_lines: usize,
33}
34
35fn time_ms(mut f: impl FnMut()) -> f64 {
36    let t = Instant::now();
37    f();
38    t.elapsed().as_secs_f64() * 1000.0
39}
40
41/// A pinch of nested-indent Rust so folding and tree-sitter have real work.
42fn synthetic_rust(lines_target: usize) -> String {
43    let mut s = String::with_capacity(lines_target * 40);
44    let mut produced = 0usize;
45    let mut i = 0usize;
46    while produced < lines_target {
47        s.push_str(&format!("fn compute_{i}(x: usize) -> usize {{\n"));
48        s.push_str("    let mut total: usize = 0; // running sum\n");
49        s.push_str("    for step in 0..x {\n");
50        s.push_str("        total += step * 2 + 1;\n");
51        s.push_str("        if total > 100 { total -= 50; }\n");
52        s.push_str("    }\n");
53        s.push_str("    total\n");
54        s.push_str("}\n");
55        s.push('\n');
56        produced += 9;
57        i += 1;
58    }
59    s
60}
61
62/// Ops/ms → Mops/s, guarding a near-zero elapsed time.
63fn mops(count: usize, ms: f64) -> f64 {
64    count as f64 / ms.max(1e-9) / 1000.0
65}
66
67fn mb_per_s(bytes: usize, ms: f64) -> f64 {
68    (bytes as f64 / 1_048_576.0) / (ms.max(1e-9) / 1000.0)
69}
70
71pub fn run(app: &App) -> BenchReport {
72    let mut results = Vec::new();
73
74    let src = synthetic_rust(4000);
75    let line_vec: Vec<String> = src.split('\n').map(|s| s.to_string()).collect();
76    let n_lines = line_vec.len();
77
78    // 1) Tree-sitter parse of the whole synthetic buffer.
79    let mut eng = SyntaxEngine::new();
80    let ms = time_ms(|| eng.parse(&src, Some("rs")));
81    let tokens = eng.tokens.len();
82    results.push(BenchResult::new(
83        "syntax parse (rust)",
84        ms,
85        format!("{n_lines} lines · {tokens} tokens · {:.0} klines/s", n_lines as f64 / ms.max(1e-9)),
86    ));
87
88    // 2) Indent-fold rebuild.
89    let mut folds = FoldState::new();
90    let ms = time_ms(|| folds.rebuild(&line_vec, 4));
91    let ranges = folds.ranges.len();
92    results.push(BenchResult::new(
93        "fold rebuild",
94        ms,
95        format!("{ranges} ranges · {:.0} klines/s", n_lines as f64 / ms.max(1e-9)),
96    ));
97
98    // 3) fold_at lookup — the per-row-per-frame call, now O(1).
99    let iters = 1_000_000usize;
100    let mut hits = 0usize;
101    let ms = time_ms(|| {
102        for i in 0..iters {
103            let row = i.wrapping_mul(2654435761) % n_lines;
104            if folds.fold_at(row).is_some() {
105                hits += 1;
106            }
107        }
108    });
109    results.push(BenchResult::new(
110        "fold_at ×1M",
111        ms,
112        format!("{:.1} Mops/s ({hits} hits)", mops(iters, ms)),
113    ));
114
115    // 4) tokens_for_row — the renderer's per-row token slice, now O(log n).
116    let reps = (1_000_000 / n_lines.max(1)).max(1);
117    let mut counted = 0usize;
118    let ms = time_ms(|| {
119        for _ in 0..reps {
120            for row in 0..n_lines {
121                counted += eng.tokens_for_row(row).len();
122            }
123        }
124    });
125    let calls = reps * n_lines;
126    results.push(BenchResult::new(
127        "tokens_for_row (render path)",
128        ms,
129        format!("{:.1} Mops/s ({calls} calls, {counted} toks)", mops(calls, ms)),
130    ));
131
132    // 5) Bulk paste — insert_str of a long single line, now O(n) not O(n²).
133    let blob = "x".repeat(200_000);
134    let mut buf = Buffer::from_string("seed");
135    buf.cursor.col = 4;
136    let ms = time_ms(|| buf.insert_str(&blob));
137    results.push(BenchResult::new(
138        "insert_str 200KB paste",
139        ms,
140        format!("{:.0} MB/s", mb_per_s(blob.len(), ms)),
141    ));
142
143    // 6) Whole-buffer join — runs once per edit before reparse.
144    let joined = Buffer::from_string(&src);
145    let ms = time_ms(|| {
146        let _ = joined.text();
147    });
148    results.push(BenchResult::new(
149        "buffer.text() join",
150        ms,
151        format!("{:.0} MB/s", mb_per_s(src.len(), ms)),
152    ));
153
154    // 7) Your actual open file, for realism.
155    let real = app.buffer.text();
156    if !real.trim().is_empty() {
157        let ext = app.file_extension();
158        let mut e2 = SyntaxEngine::new();
159        let rows = app.buffer.line_count();
160        let ms = time_ms(|| e2.parse(&real, ext.as_deref()));
161        let kb = real.len() / 1024;
162        let toks = e2.tokens.len();
163        results.push(BenchResult::new(
164            "your file: parse",
165            ms,
166            format!("{rows} lines · {kb} KB · {toks} tokens"),
167        ));
168    }
169
170    let total_ms = results.iter().map(|r| r.ms).sum();
171    BenchReport { results, total_ms, synthetic_lines: n_lines }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn run_produces_results_without_panicking() {
180        let app = App::new();
181        let report = run(&app);
182        // The six synthetic benches always run (the "your file" one is skipped
183        // for an empty buffer).
184        assert!(report.results.len() >= 6, "got {} results", report.results.len());
185        assert!(report.total_ms >= 0.0);
186        for r in &report.results {
187            assert!(r.ms >= 0.0, "{} had negative time", r.name);
188            assert!(!r.detail.is_empty());
189        }
190    }
191
192    #[test]
193    fn synthetic_source_folds_and_parses() {
194        let src = synthetic_rust(200);
195        assert!(src.lines().count() >= 200);
196        let lines: Vec<String> = src.split('\n').map(|s| s.to_string()).collect();
197        let mut folds = crate::fold::FoldState::new();
198        folds.rebuild(&lines, 4);
199        // Nested-indent template must yield foldable ranges.
200        assert!(!folds.ranges.is_empty());
201    }
202}