Skip to main content

bench/
bench.rs

1//! How long the binding's calls take on a long document — the ones a native
2//! frontend makes per interaction, so a regression in any of them is a
3//! keystroke, a click, or a scroll that lags.
4//!
5//! ```text
6//! cargo run -p leaf-ffi --example bench              # dev profile, generated document
7//! cargo run -p leaf-ffi --example bench --release    # what a shipping app sees
8//! cargo run -p leaf-ffi --example bench -- path.md   # a document of your own
9//! cargo run -p leaf-ffi --example bench -- --dump path.md   # write the generated one out
10//! ```
11//!
12//! Both profiles matter: a debug build is what a developer drives the app with
13//! and is ten times slower on exactly the scans this measures, so "fine in
14//! release" is not the whole answer. The generated document is deterministic —
15//! about 14,000 words of prose with the inline markup a design document has,
16//! plus a list, a table, and a fence — so two runs compare.
17//!
18//! This is a table, not a benchmark harness: no statistics, no dependency. The
19//! numbers are medians of a few runs each, which is enough to tell a linear
20//! scan from a lookup, and that is the question it exists to answer.
21
22use std::fmt::Write as _;
23use std::time::{Duration, Instant};
24
25use leaf_ffi::LeafDoc;
26
27fn main() {
28    let mut args = std::env::args().skip(1);
29    let source = match args.next().as_deref() {
30        // The generated document, written out — to open in the app, or to
31        // concatenate with itself and see which rows grow with it.
32        Some("--dump") => {
33            let path = args.next().expect("--dump takes a path");
34            std::fs::write(&path, generated()).unwrap_or_else(|e| panic!("{path}: {e}"));
35            return;
36        }
37        Some(path) => std::fs::read_to_string(path).unwrap_or_else(|e| panic!("{path}: {e}")),
38        None => generated(),
39    };
40    let words = source.split_whitespace().count();
41    let profile = if cfg!(debug_assertions) {
42        "dev"
43    } else {
44        "release"
45    };
46    println!(
47        "document: {} bytes, {words} words · profile: {profile}\n",
48        source.len()
49    );
50
51    let open = Instant::now();
52    let doc = LeafDoc::new(source.clone(), "markdown".into()).expect("parse");
53    let opened = open.elapsed();
54    let first = Instant::now();
55    let view = doc.set_unwrapped();
56    let first_view = first.elapsed();
57    let runs: usize = view.rows.iter().map(|r| r.runs.len()).sum();
58    println!("rows: {} · runs: {runs}", view.rows.len());
59    println!("{:<52} {:>10}", "open (twig parse)", fmt(opened));
60    println!(
61        "{:<52} {:>10}",
62        "first view (build the map + the frame)",
63        fmt(first_view)
64    );
65
66    let len = doc.doc_end_offset();
67    let mid = len / 2;
68    let mid_utf16 = doc.utf16_index_for_offset(mid);
69    let mid_row = doc.pos_for_offset(mid).row;
70
71    // Reads a frontend makes per frame or per gesture.
72    row("view() — the whole frame, unchanged", || {
73        std::hint::black_box(doc.view());
74    });
75    row("pos_for_offset (mid-document)", || {
76        std::hint::black_box(doc.pos_for_offset(mid));
77    });
78    row("utf16_index_for_offset (mid-document)", || {
79        doc.utf16_index_for_offset(mid);
80    });
81    row("offset_for_utf16_index (mid-document)", || {
82        doc.offset_for_utf16_index(mid_utf16);
83    });
84    row("text_in_range (whole document)", || {
85        std::hint::black_box(doc.text_in_range(0, len));
86    });
87    row("counts", || {
88        std::hint::black_box(doc.counts());
89    });
90
91    // Gestures: each answers with a whole frame.
92    row("click_ch (place the caret)", || {
93        std::hint::black_box(doc.click_ch(mid_row, 3, false));
94    });
95    row("click_ch extend (grow a selection)", || {
96        doc.click_ch(mid_row, 0, false);
97        std::hint::black_box(doc.click_ch(mid_row + 4, 0, true));
98    });
99    doc.click_ch(mid_row, 3, false);
100    row("selection_counts (rows selected)", || {
101        doc.click_ch(mid_row, 0, false);
102        doc.click_ch(mid_row + 4, 0, true);
103        std::hint::black_box(doc.selection_counts());
104    });
105    doc.click_ch(mid_row, 3, false);
106    row("insert one character", || {
107        std::hint::black_box(doc.insert("x".into()));
108    });
109    // The UTF-16 table is derived on the first lookup after a map rebuild,
110    // so the first conversion after a keystroke pays for it; every later one
111    // is the lookup alone.
112    row("utf16_index_for_offset (first after an edit)", || {
113        doc.insert("x".into());
114        doc.utf16_index_for_offset(mid);
115    });
116    row("newline", || {
117        std::hint::black_box(doc.newline());
118    });
119
120    // The same gestures on a document answering with changes rather than
121    // whole frames (`set_incremental_frames`): what a frontend that keeps its
122    // own copy of the rows pays. The rows each lifts are in the label, since
123    // that is the number that must not grow with the document.
124    let inc = LeafDoc::new(source, "markdown".into()).expect("parse");
125    inc.set_incremental_frames(true);
126    let _ = inc.set_unwrapped();
127    let lifted = inc.click_ch(mid_row, 3, false).rows.len();
128    row(
129        &format!("click_ch, as a change ({lifted} rows lifted)"),
130        || {
131            std::hint::black_box(inc.click_ch(mid_row, 3, false));
132        },
133    );
134    let lifted = inc.insert("x".into()).rows.len();
135    row(
136        &format!("insert one character, as a change ({lifted} rows lifted)"),
137        || {
138            std::hint::black_box(inc.insert("x".into()));
139        },
140    );
141    inc.click_ch(mid_row, 0, false);
142    let lifted = inc.click_ch(mid_row + 4, 0, true).rows.len();
143    row(
144        &format!("click_ch extend, as a change ({lifted} rows lifted)"),
145        || {
146            inc.click_ch(mid_row, 0, false);
147            std::hint::black_box(inc.click_ch(mid_row + 4, 0, true));
148        },
149    );
150}
151
152/// Time `f` a handful of times and print the median.
153fn row(label: &str, mut f: impl FnMut()) {
154    let mut samples: Vec<Duration> = (0..7)
155        .map(|_| {
156            let t = Instant::now();
157            f();
158            t.elapsed()
159        })
160        .collect();
161    samples.sort();
162    println!("{label:<52} {:>10}", fmt(samples[samples.len() / 2]));
163}
164
165fn fmt(d: Duration) -> String {
166    let us = d.as_micros();
167    if us >= 10_000 {
168        format!("{:.1} ms", us as f64 / 1000.0)
169    } else {
170        format!("{us} µs")
171    }
172}
173
174/// A long design document: prose in paragraphs of varied length with inline
175/// code, emphasis and links, under headings, with a list, a table and a fence
176/// every so often. Deterministic — no randomness — so runs compare.
177fn generated() -> String {
178    let words: Vec<&str> = "the document carries its own identity and a reference resolves \
179        against whatever archive holds it so that moving a file between checkouts changes \
180        nothing a reader can see each node names its root which is what lets a foreign \
181        parent stand in for a path nobody has written down yet"
182        .split_whitespace()
183        .collect();
184    let mut out = String::from("# A generated design document\n\n");
185    let mut w = 0usize;
186    let mut para = 0usize;
187    while w < 14_000 {
188        if para.is_multiple_of(9) {
189            let _ = writeln!(out, "## Section {}\n", para / 9 + 1);
190        }
191        match para % 11 {
192            7 => {
193                for i in 0..5 {
194                    let _ = writeln!(out, "- item {i} with `code_{i}` and *emphasis* in it");
195                    w += 8;
196                }
197                out.push('\n');
198            }
199            9 => {
200                out.push_str("| key | value | note |\n|---|---|---|\n");
201                for i in 0..4 {
202                    let _ = writeln!(out, "| `k{i}` | value {i} | a short note |");
203                    w += 6;
204                }
205                out.push('\n');
206            }
207            10 => {
208                out.push_str("```rust\nfn example() -> u32 {\n    42\n}\n```\n\n");
209                w += 6;
210            }
211            _ => {
212                let n = 40 + (para * 17) % 90;
213                for i in 0..n {
214                    let word = words[(para * 7 + i * 3) % words.len()];
215                    match (para + i) % 23 {
216                        0 => {
217                            let _ = write!(out, "`{word}_{i}` ");
218                        }
219                        5 => {
220                            let _ = write!(out, "*{word}* ");
221                        }
222                        11 => {
223                            let _ = write!(out, "[{word}](https://example.org/{para}/{i}) ");
224                        }
225                        17 => {
226                            let _ = write!(out, "**{word}** ");
227                        }
228                        _ => {
229                            out.push_str(word);
230                            out.push(' ');
231                        }
232                    }
233                }
234                w += n;
235                out.push_str("\n\n");
236            }
237        }
238        para += 1;
239    }
240    out
241}