Skip to main content

bench/
bench.rs

1//! Where does leaf's per-keystroke and per-paint time actually go?
2//!
3//! `cargo run --release -p leaf-core --example bench`
4use leaf_core::{Doc, View, source, wysiwyg};
5use std::collections::HashMap;
6use std::hash::{Hash, Hasher};
7use std::time::Instant;
8use twig::{Editor, Format};
9
10fn body(bytes: usize) -> String {
11    let mut s = String::new();
12    let mut i = 0;
13    while s.len() < bytes {
14        s.push_str(&format!(
15            "## Section {i}\n\nThe quick brown fox jumps over the lazy dog, and \
16             **bold** text with a [link](https://example.dev) and `code` besides. \
17             Another sentence follows to make the paragraph a realistic length.\n\n"
18        ));
19        i += 1;
20    }
21    s
22}
23
24fn time<T>(label: &str, n: usize, mut f: impl FnMut() -> T) -> f64 {
25    let t = Instant::now();
26    for _ in 0..n {
27        std::hint::black_box(f());
28    }
29    let ms = t.elapsed().as_secs_f64() * 1000.0 / n as f64;
30    println!("  {label:<30}{ms:8.2} ms");
31    ms
32}
33
34fn main() {
35    for kb in [10usize, 100, 1000] {
36        let src = body(kb * 1024);
37        println!("=== {} KB ===", src.len() / 1024);
38
39        let mut ed = Editor::new_str(&src, Format::Markdown).unwrap();
40        let nodes = ed.nodes().unwrap();
41        let map = wysiwyg::build(&nodes, &src, None, false, &HashMap::new(), None);
42        println!("  ({} AST nodes, {} map rows)", nodes.len(), map.rows.len());
43
44        println!("  -- per edit (unavoidable today) --");
45        time("twig edit_range (reparse)", 5, || {
46            ed.edit_range(src.len() / 2, src.len() / 2, "x").is_ok()
47        });
48        // That loop left five `x`s in the editor, and every block below measures
49        // `ed` against `src` — spans from a document five bytes longer than the
50        // string they index. Re-parse so the two are the same document again.
51        //
52        // Untimed on purpose: this is the bench putting its fixture back, not a
53        // cost leaf pays. Skipping it used to end every run in a slice panic
54        // (`push_escaped_text`, walking a span past the end of a shorter source)
55        // and would otherwise have quietly measured a build over a mismatch.
56        let mut ed = Editor::new_str(&src, Format::Markdown).unwrap();
57        time("twig nodes() FFI marshal", 5, || ed.nodes().unwrap().len());
58        time("wysiwyg::build", 5, || {
59            wysiwyg::build(&nodes, &src, None, false, &HashMap::new(), None)
60                .rows
61                .len()
62        });
63        // The source view's whole per-edit cost, next to the WYSIWYG view's, so
64        // the "cheaper view" claim in `source::build`'s docs is a measured one.
65        // It has no incremental path: this plus the marshal above is what a
66        // keystroke in `View::Source` pays.
67        time("source::build", 5, || {
68            source::build(&nodes, &src).runs().len()
69        });
70        {
71            // The incremental path with a warm cache and nothing changed: the
72            // floor cost the block cache adds even on a pure repaint — hash every
73            // block, clone every reused row, recollect stops. No subtree is
74            // marshalled (every block hits). The real keystroke win shows up in
75            // "Doc::insert + rebuild" below, which re-marshals only the edited
76            // block and reuses the rest.
77            let mut cache = wysiwyg::BlockCache::default();
78            let top = ed.child_spans(None).unwrap();
79            let _ = wysiwyg::build_cached(
80                &top,
81                &src,
82                None,
83                false,
84                &HashMap::new(),
85                None,
86                &mut cache,
87                |id| ed.subtree(twig::NodeId(id)).unwrap_or_default(),
88            );
89            time("wysiwyg::build_cached (all reused)", 5, || {
90                let top = ed.child_spans(None).unwrap();
91                wysiwyg::build_cached(
92                    &top,
93                    &src,
94                    None,
95                    false,
96                    &HashMap::new(),
97                    None,
98                    &mut cache,
99                    |id| ed.subtree(twig::NodeId(id)).unwrap_or_default(),
100                )
101                .rows
102                .len()
103            });
104        }
105
106        println!("  -- claimed hot, actually noise --");
107        time("twig source_str() (full copy)", 5, || {
108            ed.source_str().unwrap().len()
109        });
110        let clean = src.clone();
111        time("dirty compare (full cmp)", 5, || src == clean);
112
113        println!("  -- what the GUI adds on a cache miss --");
114        time("clone every row's glyphs", 5, || {
115            map.rows
116                .iter()
117                .map(|r| r.glyphs.clone())
118                .collect::<Vec<_>>()
119                .len()
120        });
121        time("hash every glyph (cache key?)", 5, || {
122            let mut n = 0u64;
123            for r in &map.rows {
124                let mut h = std::collections::hash_map::DefaultHasher::new();
125                for g in &r.glyphs {
126                    g.ch.hash(&mut h);
127                }
128                n ^= h.finish();
129            }
130            n
131        });
132
133        println!("  -- the whole path, as a frontend calls it --");
134        let mut p = std::env::temp_dir();
135        p.push(format!("leaf_bench_{kb}.md"));
136        std::fs::write(&p, &src).unwrap();
137        let mut d = Doc::open(p).unwrap();
138        d.view = View::Wysiwyg;
139        d.place_caret(src.len() / 2, false);
140        d.build_visual_unwrapped();
141        time("build_visual (cached: a repaint)", 200, || {
142            d.build_visual_unwrapped()
143        });
144        time("Doc::insert + rebuild (a keystroke)", 5, || {
145            d.insert("x");
146            d.build_visual_unwrapped();
147        });
148        println!();
149    }
150}