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