Skip to main content

max / quasi

20.8 KB · 512 lines History Blame Raw
1 //! What a described screen costs to render.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! Four renderers sit on one description layer, so a cost introduced in the
6 //! description tier is paid by all of them at once.
7 //!
8 //! # Running it
9 //!
10 //! Two builds, because the second one instruments the allocator and a timing
11 //! number taken through instrumentation is a number about the instrumentation.
12 //!
13 //! ```text
14 //! cargo run --release -p quasi-bench # nanoseconds per render
15 //! cargo run --release -p quasi-bench --features count # allocations per render
16 //! ```
17 //!
18 //! And one diagnostic, off unless asked for, which attributes the terminal
19 //! column's allocations between the rebuild, the pre-clip walk and the drawing:
20 //!
21 //! ```text
22 //! QUASI_TUI_BREAKDOWN=1 cargo run --release -p quasi-bench --features count
23 //! ```
24 //!
25 //! Release, always. A debug build measures `opt-level = 1` and answers a
26 //! question nobody asked.
27 //!
28 //! # What to read
29 //!
30 //! The allocation count, first and mostly. It is stable across machines and
31 //! across whatever else the box is doing, and it is the thing the emitter work
32 //! moves. The timing is a sanity check beside it, and on a shared dev box a
33 //! 10% difference in it is noise.
34 //!
35 //! Read down the row-count column rather than across renderers. Comparing the
36 //! webview's number to the terminal's is comparing a string builder to a cell
37 //! painter and means very little.
38 //!
39 //! # Which renderers, and what each column's unit is
40 //!
41 //! All three: webview, terminal, immediate. The last arrived with `immediate`,
42 //! which stands up a real egui `Context` and runs a frame through it, because
43 //! `Immediate::screen(&self, ui: &mut Ui, ..)` needs a live `Ui` and a `Ui` is
44 //! only handed out inside a frame egui is running.
45 //!
46 //! The three units are not the same thing and the table does not pretend they
47 //! are:
48 //!
49 //! - **webview** emits every row into a string, so its cost rises with the
50 //! rows. It is measured twice, as a fragment and as a whole screen, because
51 //! those are the two things a route answers and the shell is not free.
52 //! - **terminal** and **immediate** draw one screenful and leave the rest to
53 //! scroll state, so neither *has* to pay per row past the viewport.
54 //!
55 //! Measured on fw13, 2026-09-03. Growth is 5 rows to 200, which is 40x the
56 //! data:
57 //!
58 //! ```text
59 //! allocations per frame nanoseconds per frame
60 //! rows terminal immediate webview terminal immediate webview
61 //! 5 2475 339 234 326596 56211 7407
62 //! 25 4250 973 916 434859 192385 30380
63 //! 200 15815 1186 6869 605476 243559 215603
64 //! growth 6.4x 3.5x 29.4x 1.9x 4.3x 29.1x
65 //! ```
66 //!
67 //! **The two halves disagree about the terminal, and the allocation half is
68 //! the one this bench believes.** In time the terminal column is nearly flat,
69 //! which is what this header predicted; in allocations it is not, and it
70 //! allocates 13x what the immediate renderer does at 200 rows. So the
71 //! prediction was right about the number it is cheapest to look at and wrong
72 //! about the number it says to read.
73 //!
74 //! The immediate column is the one that actually flattens where the window
75 //! fills: past 25 rows, eight times the data buys 22% more allocation and 27%
76 //! more time. That is the shape a clipping renderer is supposed to have, and
77 //! it is what says a compiled path would be attacking a cost that does not
78 //! scale here. The webview, at 29x on both halves, is where compiling pays.
79 //!
80 //! Two asymmetries to hold before reading those two columns against each other.
81 //!
82 //! 1. **Entry point.** The terminal is measured through `Tui::node`, over the
83 //! pane alone; the immediate renderer has no node-level entry point, so it
84 //! goes through `Immediate::screen`, over the pane inside a screen. The
85 //! immediate number carries the screen walk (notices, regions, the frame)
86 //! and the terminal number does not.
87 //! 2. **The rebuild.** The terminal cell rebuilds the description every
88 //! iteration and the immediate cell does not, which is `fixture::pane`'s
89 //! stated rule meeting a host that has no requests. Subtract the `build
90 //! tree` column to compare the draws: at 200 rows that is 15815 - 5418 =
91 //! 10397 against 1186. The rule was written for the webview, where a request
92 //! really does pay for the tree; a terminal host redraws a description it
93 //! already has, the same way an immediate one does, so the terminal column
94 //! is arguably charging for something too. Left as it is rather than
95 //! silently restated, because it is a number that has been read before.
96 //!
97 //! Both columns still answer the question this bench exists for, which is what
98 //! a row costs, and that is read down a column.
99
100 mod charted;
101 mod declared;
102 mod dispatched;
103 mod fixture;
104 mod guarded;
105 mod immediate;
106 mod marked;
107 mod paged;
108 mod spliced;
109 mod staging;
110
111 #[cfg(feature = "askama")]
112 mod compiled;
113
114 #[cfg(feature = "count")]
115 mod counting;
116
117 use std::hint::black_box;
118 #[cfg(not(feature = "count"))]
119 use std::time::Instant;
120
121 use makeover_layout as layout;
122 use makeover_tui::{Fidelity, Theme};
123 use quasi_http::Serves;
124 use quasi_router::{Node, RegionKind, Screen, Slot};
125 use quasi_tui::{Tui, View};
126 use quasi_webview::Webview;
127 use ratatui::buffer::Buffer;
128 use ratatui::layout::Rect;
129
130 /// Iterations per pass in a timing run.
131 #[cfg(not(feature = "count"))]
132 const ITERATIONS: u32 = 20_000;
133
134 /// Passes, of which the best is reported.
135 ///
136 /// Best rather than mean: the distribution's left edge is the machine doing the
137 /// work with nothing in the way, and everything above it is something else the
138 /// box was doing. A mean on a dev box measures the browser in the other
139 /// workspace.
140 #[cfg(not(feature = "count"))]
141 const PASSES: u32 = 5;
142
143 /// Iterations before the clock starts, to warm caches and settle the allocator.
144 #[cfg(not(feature = "count"))]
145 const WARMUP: u32 = 2_000;
146
147 /// The terminal's viewport.
148 ///
149 /// Wide enough that the narrowing pass keeps every column, so the terminal is
150 /// drawing the same table the webview is, and a real screenful tall rather than
151 /// tall enough to hold every row.
152 ///
153 /// The height is the load-bearing choice and it is what makes the terminal
154 /// column mean something different from the webview's. A webview emits every
155 /// row into a string the browser scrolls; a terminal draws one frame and the
156 /// rest is scroll state. Sizing the viewport to the content would measure a
157 /// screen no terminal ever draws, and the `Buffer::empty`/`reset` cost of a
158 /// 400-row viewport swamped everything else when this bench first ran.
159 ///
160 /// So read the terminal column as cost per frame. This said to expect it nearly
161 /// flat across the row counts, and in nanoseconds it is: 1.9x for 40x the rows.
162 /// In allocations it is not, at 6.4x, and allocations are what this bench says
163 /// to read. Both numbers are in the header. What the gap between them means is
164 /// that the terminal's per-row cost is real and is being paid somewhere the
165 /// clock does not see it, which is the description being walked before the
166 /// drawing is clipped.
167 const TERMINAL: Rect = Rect {
168 x: 0,
169 y: 0,
170 width: 160,
171 height: 50,
172 };
173
174 fn main() {
175 let tui = terminal_renderer();
176 let webview = Webview::new();
177 // The egui context is built once and reused for every frame of every row
178 // count, which is what a host does and what keeps the font atlas out of
179 // the numbers. See `immediate`'s header.
180 let ctx = immediate::context();
181 let egui_renderer = immediate::renderer();
182
183 #[cfg(feature = "count")]
184 let _session = counting::Session::start();
185
186 #[cfg(feature = "count")]
187 println!("allocations per render (allocs / bytes)\n");
188 #[cfg(not(feature = "count"))]
189 println!("nanoseconds per render, best of {PASSES} passes of {ITERATIONS}\n");
190
191 println!(
192 "{:>5} {:>13} {:>13} {:>13} {:>13} {:>13} {:>13} {:>13} {:>13}",
193 "rows",
194 "build tree",
195 "render tree",
196 "build+render",
197 "staged",
198 "declared",
199 COMPILED_HEAD,
200 "terminal",
201 "immediate"
202 );
203
204 // The residual, emitted once. A build-time artifact in a real host; emitted
205 // here at startup so the spike needs no build script to answer the
206 // question it exists to answer.
207 let program = staging::emit();
208
209 // The proposal, written to disk: the description compiled to an Askama
210 // template. Not on the timing path; this is how the file that the `askama`
211 // feature compiles gets regenerated when the description changes.
212 if let Ok(path) = std::env::var("QUASI_EMIT_TEMPLATE") {
213 std::fs::write(&path, staging::as_askama_template(&program))
214 .expect("the emitted template is writable");
215 println!("wrote {path}");
216 return;
217 }
218
219 // The declared screen's residual, read off its staged twin. The `staged`
220 // column beside it is the spike's hand-written copy and stays for
221 // comparison: that copy dropped `navigating` and three settings on Revoke,
222 // so it renders a slightly smaller screen and the two columns are not
223 // measuring the same markup. See `declared`.
224 let residual = quasi_webview::stage::derive(&webview, declared::pane_staged);
225
226 check_staged_matches(&webview, &program);
227 check_declared_matches(&webview, &residual);
228
229 for size in fixture::SIZES {
230 let rows = fixture::Rows::new(size);
231
232 // The tree on its own: what a description costs to construct before
233 // anything has been rendered. This is the half staging deletes.
234 let build = measure(|| {
235 black_box(fixture::pane(black_box(&rows)));
236 });
237 // The renderer on its own, over a tree it did not have to build. The
238 // closest thing in this bench to what a compiled template does, and
239 // therefore the floor the staged column is trying to reach.
240 let prebuilt = fixture::pane(&rows);
241 let render = measure(|| {
242 black_box(webview.fragment(black_box(&prebuilt)));
243 });
244 let staged = measure(|| {
245 let mut out = String::new();
246 staging::exec(&program, black_box(&rows), &mut out);
247 black_box(out);
248 });
249 // The same thing from a declaration rather than from a hand-written
250 // `describe`: no `Node` is built, and the markup is the renderer's own.
251 let declared = measure(|| {
252 black_box(declared::pane_serve(
253 &residual,
254 black_box(&rows.buyers),
255 black_box(&rows.shared),
256 ));
257 });
258
259 let fragment = measure(|| {
260 let node = fixture::pane(&rows);
261 black_box(webview.fragment(black_box(&node)));
262 });
263 let screen = measure(|| {
264 let node = fixture::pane(&rows);
265 black_box(webview.screen(black_box(&as_screen(node))));
266 });
267 // The buffer is hoisted and reset rather than rebuilt. A terminal host
268 // owns one across frames, and `Buffer::empty` for this viewport is
269 // 64,000 cells: leaving it in the loop measured the allocation and
270 // buried the drawing under it, which is what the first run of this
271 // bench actually reported.
272 let mut buf = Buffer::empty(TERMINAL);
273 let terminal = measure(|| {
274 let node = fixture::pane(&rows);
275 buf.reset();
276 tui.node(black_box(&node), &View::new(), TERMINAL, &mut buf);
277 black_box(&buf);
278 });
279
280 // The screen and the view are hoisted for the buffer's reason, one
281 // step further: an immediate-mode host redraws a screen it already
282 // has, and the view is what remembers which row is current between
283 // frames. `immediate::frame` says why in full.
284 let drawn = as_screen(fixture::pane(&rows));
285 let mut view = quasi_immediate::View::new();
286 let immediate = measure(|| {
287 immediate::frame(&ctx, &egui_renderer, black_box(&drawn), &mut view);
288 });
289
290 // Where the terminal frame's allocations go, by difference rather than
291 // by profiler. Off unless asked for, because it is a diagnostic about
292 // one column and not part of the table.
293 //
294 // Each row of it isolates one candidate. `reset` is the hoisted
295 // buffer's own cost. `draw` is the frame with the description already
296 // built, so `terminal` minus `draw` is the rebuild. `h1` is the same
297 // draw into a one-row viewport, which is everything that happens before
298 // anything is clipped. `h400` is a viewport tall enough to hold the
299 // whole table, so `h400` minus `h1` is the drawing itself. `webview` is
300 // the control: the same prebuilt tree emitted in full, by a renderer
301 // with no viewport to clip against.
302 //
303 // See GoingsOn `fc76b1ce` for what it answered.
304 if std::env::var("QUASI_TUI_BREAKDOWN").is_ok() {
305 let prebuilt_pane = fixture::pane(&rows);
306
307 // Draw only: the same frame with the description already built.
308 let mut b1 = Buffer::empty(TERMINAL);
309 let draw_only = measure(|| {
310 b1.reset();
311 tui.node(black_box(&prebuilt_pane), &View::new(), TERMINAL, &mut b1);
312 black_box(&b1);
313 });
314
315 // The reset alone, with no drawing at all.
316 let mut b2 = Buffer::empty(TERMINAL);
317 let reset_only = measure(|| {
318 b2.reset();
319 black_box(&b2);
320 });
321
322 // A viewport eight times taller. If the count barely moves, the
323 // rows past the fold are already being paid for.
324 let tall = Rect {
325 height: 400,
326 ..TERMINAL
327 };
328 let mut b3 = Buffer::empty(tall);
329 let tall_draw = measure(|| {
330 b3.reset();
331 tui.node(black_box(&prebuilt_pane), &View::new(), tall, &mut b3);
332 black_box(&b3);
333 });
334
335 // A viewport one row tall. If the count barely moves down, the
336 // walk is not clipped at all.
337 let short = Rect {
338 height: 1,
339 ..TERMINAL
340 };
341 let mut b4 = Buffer::empty(short);
342 let short_draw = measure(|| {
343 b4.reset();
344 tui.node(black_box(&prebuilt_pane), &View::new(), short, &mut b4);
345 black_box(&b4);
346 });
347
348 // The webview over the same prebuilt tree, as the control: it has
349 // no viewport and must pay per row.
350 let webview_draw = measure(|| {
351 black_box(webview.fragment(black_box(&prebuilt_pane)));
352 });
353
354 println!(
355 " BREAKDOWN {size:>4} rows: draw {draw_only}, reset {reset_only}, \
356 h400 {tall_draw}, h1 {short_draw}, webview {webview_draw}"
357 );
358 }
359
360 let compiled = measure_compiled(&webview, &rows, &screen);
361
362 println!(
363 "{size:>5} {build:>13} {render:>13} {fragment:>13} {staged:>13} \
364 {declared:>13} {compiled:>13} {terminal:>13} {immediate:>13}"
365 );
366 }
367
368 #[cfg(not(feature = "count"))]
369 println!(
370 "\nAllocation counts are the stable half and are not in this run.\n\
371 Take them with: cargo run --release -p quasi-bench --features count"
372 );
373 }
374
375 /// The declared screen's residual serves what the renderer serves.
376 ///
377 /// The same check `check_staged_matches` makes, against the declared path. A
378 /// staged number for a program that does not reproduce the screen would be a
379 /// number about nothing, and the empty case is included because a residual
380 /// carries branches now: a screen with no rows takes the guards the other way
381 /// and is the case the spike could not express at all.
382 fn check_declared_matches(webview: &Webview, residual: &quasi_router::stage::Residual) {
383 for size in [0].into_iter().chain(fixture::SIZES) {
384 let rows = fixture::Rows::new(size);
385 let direct = webview.fragment(&declared::describe(&rows));
386 let filled = declared::pane_serve(residual, &rows.buyers, &rows.shared);
387 assert!(
388 direct == filled,
389 "the declared residual differs from the renderer at {size} rows"
390 );
391 }
392 println!("declared residual verified against the renderer, empty screen included\n");
393 }
394
395 /// A renderer in a shipped theme, at full colour.
396 ///
397 /// Through a bundled theme file rather than a literal because
398 /// `makeover_tui::Theme` is `#[non_exhaustive]` and `Theme::from_theme` is the
399 /// only way to get one. The same construction quasi-tui's own tests use.
400 fn terminal_renderer() -> Tui {
401 let dir = makeover::bundled_themes_dir().expect("makeover ships themes");
402 let colours = makeover::load_theme(&[(dir, false)], "goingson").expect("a bundled theme loads");
403 Tui::new(
404 Theme::from_theme(&colours).expect("a shipped theme resolves"),
405 Fidelity::TrueColor,
406 )
407 }
408
409 /// The pane as a whole document, for the screen measurement.
410 ///
411 /// A screen rather than a fragment is what a navigation answers, and it carries
412 /// the shell: head, title, the stylesheet links. Measuring only fragments would
413 /// report a per-request cost the app does not actually have on first load.
414 fn as_screen(pane: Node) -> Screen {
415 Screen::new("Contacts", layout::Arrangement::list_detail(true))
416 .with(Slot::new("main", RegionKind::Pane).with(pane))
417 }
418
419 /// One cell of the table.
420 ///
421 /// Under `count` this is an allocation count for a single render; under a
422 /// timing build it is nanoseconds. Two shapes, one call site, because the whole
423 /// point is that the two runs report the same grid.
424 #[cfg(not(feature = "count"))]
425 fn measure(mut render: impl FnMut()) -> String {
426 for _ in 0..WARMUP {
427 render();
428 }
429 let mut best = u128::MAX;
430 for _ in 0..PASSES {
431 let start = Instant::now();
432 for _ in 0..ITERATIONS {
433 render();
434 }
435 best = best.min(start.elapsed().as_nanos() / u128::from(ITERATIONS));
436 }
437 format!("{best}")
438 }
439
440 /// One cell of the table, counting rather than timing.
441 ///
442 /// One render, not a loop. An allocation count is exact, so averaging it over
443 /// 20,000 iterations would divide a number by itself and add rounding.
444 #[cfg(feature = "count")]
445 fn measure(mut render: impl FnMut()) -> String {
446 // One untimed render first: the first call through a renderer populates
447 // whatever it lazily builds, and counting that would charge the screen for
448 // a cost the second request does not pay.
449 render();
450
451 let (blocks_before, bytes_before) = counting::read();
452 render();
453 let (blocks_after, bytes_after) = counting::read();
454
455 format!(
456 "{} / {}",
457 blocks_after - blocks_before,
458 bytes_after - bytes_before
459 )
460 }
461
462 /// The residual must produce what the renderer produces.
463 ///
464 /// Byte-identity is the right assertion *here* and nowhere else in this design:
465 /// it is not an acceptance test for staging, it is the check that the emitter
466 /// dropped nothing. The staged path wins by deleting the tree, not by changing
467 /// the markup, so at this stage the bytes should still agree. Every further
468 /// optimisation available to an emitter (fusing adjacent literals, hoisting
469 /// per-row constants, dropping wrappers whose region name is resolved at emit
470 /// time) changes the bytes on purpose, and this assertion is what would be
471 /// relaxed to take them.
472 fn check_staged_matches(webview: &Webview, program: &[staging::Op]) {
473 for size in fixture::SIZES {
474 let rows = fixture::Rows::new(size);
475 let direct = webview.fragment(&fixture::pane(&rows));
476 let mut staged = String::new();
477 staging::exec(program, &rows, &mut staged);
478 assert_eq!(
479 direct.len(),
480 staged.len(),
481 "staged output differs in length at {size} rows"
482 );
483 assert!(
484 direct == staged,
485 "staged output differs from the renderer at {size} rows"
486 );
487 }
488 println!("residual verified against the renderer at every row count\n");
489 }
490
491 /// The heading of the sixth column, which depends on what was compiled in.
492 #[cfg(feature = "askama")]
493 const COMPILED_HEAD: &str = "askama";
494 #[cfg(not(feature = "askama"))]
495 const COMPILED_HEAD: &str = "webview screen";
496
497 /// The generated template, rendered.
498 #[cfg(feature = "askama")]
499 fn measure_compiled(_webview: &Webview, rows: &fixture::Rows, _screen: &str) -> String {
500 use askama::Template as _;
501 measure(|| {
502 let page = compiled::Contacts::new(black_box(rows));
503 black_box(page.render().expect("the generated template renders"));
504 })
505 }
506
507 /// Without the feature this column stays what it was: the whole-document cost.
508 #[cfg(not(feature = "askama"))]
509 fn measure_compiled(_webview: &Webview, _rows: &fixture::Rows, screen: &str) -> String {
510 screen.to_string()
511 }
512