//! What a described screen costs to render. //! //! //! //! Four renderers sit on one description layer, so a cost introduced in the //! description tier is paid by all of them at once. //! //! # Running it //! //! Two builds, because the second one instruments the allocator and a timing //! number taken through instrumentation is a number about the instrumentation. //! //! ```text //! cargo run --release -p quasi-bench # nanoseconds per render //! cargo run --release -p quasi-bench --features count # allocations per render //! ``` //! //! And one diagnostic, off unless asked for, which attributes the terminal //! column's allocations between the rebuild, the pre-clip walk and the drawing: //! //! ```text //! QUASI_TUI_BREAKDOWN=1 cargo run --release -p quasi-bench --features count //! ``` //! //! Release, always. A debug build measures `opt-level = 1` and answers a //! question nobody asked. //! //! # What to read //! //! The allocation count, first and mostly. It is stable across machines and //! across whatever else the box is doing, and it is the thing the emitter work //! moves. The timing is a sanity check beside it, and on a shared dev box a //! 10% difference in it is noise. //! //! Read down the row-count column rather than across renderers. Comparing the //! webview's number to the terminal's is comparing a string builder to a cell //! painter and means very little. //! //! # Which renderers, and what each column's unit is //! //! All three: webview, terminal, immediate. The last arrived with `immediate`, //! which stands up a real egui `Context` and runs a frame through it, because //! `Immediate::screen(&self, ui: &mut Ui, ..)` needs a live `Ui` and a `Ui` is //! only handed out inside a frame egui is running. //! //! The three units are not the same thing and the table does not pretend they //! are: //! //! - **webview** emits every row into a string, so its cost rises with the //! rows. It is measured twice, as a fragment and as a whole screen, because //! those are the two things a route answers and the shell is not free. //! - **terminal** and **immediate** draw one screenful and leave the rest to //! scroll state, so neither *has* to pay per row past the viewport. //! //! Measured on fw13, 2026-09-03. Growth is 5 rows to 200, which is 40x the //! data: //! //! ```text //! allocations per frame nanoseconds per frame //! rows terminal immediate webview terminal immediate webview //! 5 2475 339 234 326596 56211 7407 //! 25 4250 973 916 434859 192385 30380 //! 200 15815 1186 6869 605476 243559 215603 //! growth 6.4x 3.5x 29.4x 1.9x 4.3x 29.1x //! ``` //! //! **The two halves disagree about the terminal, and the allocation half is //! the one this bench believes.** In time the terminal column is nearly flat, //! which is what this header predicted; in allocations it is not, and it //! allocates 13x what the immediate renderer does at 200 rows. So the //! prediction was right about the number it is cheapest to look at and wrong //! about the number it says to read. //! //! The immediate column is the one that actually flattens where the window //! fills: past 25 rows, eight times the data buys 22% more allocation and 27% //! more time. That is the shape a clipping renderer is supposed to have, and //! it is what says a compiled path would be attacking a cost that does not //! scale here. The webview, at 29x on both halves, is where compiling pays. //! //! Two asymmetries to hold before reading those two columns against each other. //! //! 1. **Entry point.** The terminal is measured through `Tui::node`, over the //! pane alone; the immediate renderer has no node-level entry point, so it //! goes through `Immediate::screen`, over the pane inside a screen. The //! immediate number carries the screen walk (notices, regions, the frame) //! and the terminal number does not. //! 2. **The rebuild.** The terminal cell rebuilds the description every //! iteration and the immediate cell does not, which is `fixture::pane`'s //! stated rule meeting a host that has no requests. Subtract the `build //! tree` column to compare the draws: at 200 rows that is 15815 - 5418 = //! 10397 against 1186. The rule was written for the webview, where a request //! really does pay for the tree; a terminal host redraws a description it //! already has, the same way an immediate one does, so the terminal column //! is arguably charging for something too. Left as it is rather than //! silently restated, because it is a number that has been read before. //! //! Both columns still answer the question this bench exists for, which is what //! a row costs, and that is read down a column. mod charted; mod declared; mod dispatched; mod fixture; mod guarded; mod immediate; mod marked; mod paged; mod spliced; mod staging; #[cfg(feature = "askama")] mod compiled; #[cfg(feature = "count")] mod counting; use std::hint::black_box; #[cfg(not(feature = "count"))] use std::time::Instant; use makeover_layout as layout; use makeover_tui::{Fidelity, Theme}; use quasi_http::Serves; use quasi_router::{Node, RegionKind, Screen, Slot}; use quasi_tui::{Tui, View}; use quasi_webview::Webview; use ratatui::buffer::Buffer; use ratatui::layout::Rect; /// Iterations per pass in a timing run. #[cfg(not(feature = "count"))] const ITERATIONS: u32 = 20_000; /// Passes, of which the best is reported. /// /// Best rather than mean: the distribution's left edge is the machine doing the /// work with nothing in the way, and everything above it is something else the /// box was doing. A mean on a dev box measures the browser in the other /// workspace. #[cfg(not(feature = "count"))] const PASSES: u32 = 5; /// Iterations before the clock starts, to warm caches and settle the allocator. #[cfg(not(feature = "count"))] const WARMUP: u32 = 2_000; /// The terminal's viewport. /// /// Wide enough that the narrowing pass keeps every column, so the terminal is /// drawing the same table the webview is, and a real screenful tall rather than /// tall enough to hold every row. /// /// The height is the load-bearing choice and it is what makes the terminal /// column mean something different from the webview's. A webview emits every /// row into a string the browser scrolls; a terminal draws one frame and the /// rest is scroll state. Sizing the viewport to the content would measure a /// screen no terminal ever draws, and the `Buffer::empty`/`reset` cost of a /// 400-row viewport swamped everything else when this bench first ran. /// /// So read the terminal column as cost per frame. This said to expect it nearly /// flat across the row counts, and in nanoseconds it is: 1.9x for 40x the rows. /// In allocations it is not, at 6.4x, and allocations are what this bench says /// to read. Both numbers are in the header. What the gap between them means is /// that the terminal's per-row cost is real and is being paid somewhere the /// clock does not see it, which is the description being walked before the /// drawing is clipped. const TERMINAL: Rect = Rect { x: 0, y: 0, width: 160, height: 50, }; fn main() { let tui = terminal_renderer(); let webview = Webview::new(); // The egui context is built once and reused for every frame of every row // count, which is what a host does and what keeps the font atlas out of // the numbers. See `immediate`'s header. let ctx = immediate::context(); let egui_renderer = immediate::renderer(); #[cfg(feature = "count")] let _session = counting::Session::start(); #[cfg(feature = "count")] println!("allocations per render (allocs / bytes)\n"); #[cfg(not(feature = "count"))] println!("nanoseconds per render, best of {PASSES} passes of {ITERATIONS}\n"); println!( "{:>5} {:>13} {:>13} {:>13} {:>13} {:>13} {:>13} {:>13} {:>13}", "rows", "build tree", "render tree", "build+render", "staged", "declared", COMPILED_HEAD, "terminal", "immediate" ); // The residual, emitted once. A build-time artifact in a real host; emitted // here at startup so the spike needs no build script to answer the // question it exists to answer. let program = staging::emit(); // The proposal, written to disk: the description compiled to an Askama // template. Not on the timing path; this is how the file that the `askama` // feature compiles gets regenerated when the description changes. if let Ok(path) = std::env::var("QUASI_EMIT_TEMPLATE") { std::fs::write(&path, staging::as_askama_template(&program)) .expect("the emitted template is writable"); println!("wrote {path}"); return; } // The declared screen's residual, read off its staged twin. The `staged` // column beside it is the spike's hand-written copy and stays for // comparison: that copy dropped `navigating` and three settings on Revoke, // so it renders a slightly smaller screen and the two columns are not // measuring the same markup. See `declared`. let residual = quasi_webview::stage::derive(&webview, declared::pane_staged); check_staged_matches(&webview, &program); check_declared_matches(&webview, &residual); for size in fixture::SIZES { let rows = fixture::Rows::new(size); // The tree on its own: what a description costs to construct before // anything has been rendered. This is the half staging deletes. let build = measure(|| { black_box(fixture::pane(black_box(&rows))); }); // The renderer on its own, over a tree it did not have to build. The // closest thing in this bench to what a compiled template does, and // therefore the floor the staged column is trying to reach. let prebuilt = fixture::pane(&rows); let render = measure(|| { black_box(webview.fragment(black_box(&prebuilt))); }); let staged = measure(|| { let mut out = String::new(); staging::exec(&program, black_box(&rows), &mut out); black_box(out); }); // The same thing from a declaration rather than from a hand-written // `describe`: no `Node` is built, and the markup is the renderer's own. let declared = measure(|| { black_box(declared::pane_serve( &residual, black_box(&rows.buyers), black_box(&rows.shared), )); }); let fragment = measure(|| { let node = fixture::pane(&rows); black_box(webview.fragment(black_box(&node))); }); let screen = measure(|| { let node = fixture::pane(&rows); black_box(webview.screen(black_box(&as_screen(node)))); }); // The buffer is hoisted and reset rather than rebuilt. A terminal host // owns one across frames, and `Buffer::empty` for this viewport is // 64,000 cells: leaving it in the loop measured the allocation and // buried the drawing under it, which is what the first run of this // bench actually reported. let mut buf = Buffer::empty(TERMINAL); let terminal = measure(|| { let node = fixture::pane(&rows); buf.reset(); tui.node(black_box(&node), &View::new(), TERMINAL, &mut buf); black_box(&buf); }); // The screen and the view are hoisted for the buffer's reason, one // step further: an immediate-mode host redraws a screen it already // has, and the view is what remembers which row is current between // frames. `immediate::frame` says why in full. let drawn = as_screen(fixture::pane(&rows)); let mut view = quasi_immediate::View::new(); let immediate = measure(|| { immediate::frame(&ctx, &egui_renderer, black_box(&drawn), &mut view); }); // Where the terminal frame's allocations go, by difference rather than // by profiler. Off unless asked for, because it is a diagnostic about // one column and not part of the table. // // Each row of it isolates one candidate. `reset` is the hoisted // buffer's own cost. `draw` is the frame with the description already // built, so `terminal` minus `draw` is the rebuild. `h1` is the same // draw into a one-row viewport, which is everything that happens before // anything is clipped. `h400` is a viewport tall enough to hold the // whole table, so `h400` minus `h1` is the drawing itself. `webview` is // the control: the same prebuilt tree emitted in full, by a renderer // with no viewport to clip against. // // See GoingsOn `fc76b1ce` for what it answered. if std::env::var("QUASI_TUI_BREAKDOWN").is_ok() { let prebuilt_pane = fixture::pane(&rows); // Draw only: the same frame with the description already built. let mut b1 = Buffer::empty(TERMINAL); let draw_only = measure(|| { b1.reset(); tui.node(black_box(&prebuilt_pane), &View::new(), TERMINAL, &mut b1); black_box(&b1); }); // The reset alone, with no drawing at all. let mut b2 = Buffer::empty(TERMINAL); let reset_only = measure(|| { b2.reset(); black_box(&b2); }); // A viewport eight times taller. If the count barely moves, the // rows past the fold are already being paid for. let tall = Rect { height: 400, ..TERMINAL }; let mut b3 = Buffer::empty(tall); let tall_draw = measure(|| { b3.reset(); tui.node(black_box(&prebuilt_pane), &View::new(), tall, &mut b3); black_box(&b3); }); // A viewport one row tall. If the count barely moves down, the // walk is not clipped at all. let short = Rect { height: 1, ..TERMINAL }; let mut b4 = Buffer::empty(short); let short_draw = measure(|| { b4.reset(); tui.node(black_box(&prebuilt_pane), &View::new(), short, &mut b4); black_box(&b4); }); // The webview over the same prebuilt tree, as the control: it has // no viewport and must pay per row. let webview_draw = measure(|| { black_box(webview.fragment(black_box(&prebuilt_pane))); }); println!( " BREAKDOWN {size:>4} rows: draw {draw_only}, reset {reset_only}, \ h400 {tall_draw}, h1 {short_draw}, webview {webview_draw}" ); } let compiled = measure_compiled(&webview, &rows, &screen); println!( "{size:>5} {build:>13} {render:>13} {fragment:>13} {staged:>13} \ {declared:>13} {compiled:>13} {terminal:>13} {immediate:>13}" ); } #[cfg(not(feature = "count"))] println!( "\nAllocation counts are the stable half and are not in this run.\n\ Take them with: cargo run --release -p quasi-bench --features count" ); } /// The declared screen's residual serves what the renderer serves. /// /// The same check `check_staged_matches` makes, against the declared path. A /// staged number for a program that does not reproduce the screen would be a /// number about nothing, and the empty case is included because a residual /// carries branches now: a screen with no rows takes the guards the other way /// and is the case the spike could not express at all. fn check_declared_matches(webview: &Webview, residual: &quasi_router::stage::Residual) { for size in [0].into_iter().chain(fixture::SIZES) { let rows = fixture::Rows::new(size); let direct = webview.fragment(&declared::describe(&rows)); let filled = declared::pane_serve(residual, &rows.buyers, &rows.shared); assert!( direct == filled, "the declared residual differs from the renderer at {size} rows" ); } println!("declared residual verified against the renderer, empty screen included\n"); } /// A renderer in a shipped theme, at full colour. /// /// Through a bundled theme file rather than a literal because /// `makeover_tui::Theme` is `#[non_exhaustive]` and `Theme::from_theme` is the /// only way to get one. The same construction quasi-tui's own tests use. fn terminal_renderer() -> Tui { let dir = makeover::bundled_themes_dir().expect("makeover ships themes"); let colours = makeover::load_theme(&[(dir, false)], "goingson").expect("a bundled theme loads"); Tui::new( Theme::from_theme(&colours).expect("a shipped theme resolves"), Fidelity::TrueColor, ) } /// The pane as a whole document, for the screen measurement. /// /// A screen rather than a fragment is what a navigation answers, and it carries /// the shell: head, title, the stylesheet links. Measuring only fragments would /// report a per-request cost the app does not actually have on first load. fn as_screen(pane: Node) -> Screen { Screen::new("Contacts", layout::Arrangement::list_detail(true)) .with(Slot::new("main", RegionKind::Pane).with(pane)) } /// One cell of the table. /// /// Under `count` this is an allocation count for a single render; under a /// timing build it is nanoseconds. Two shapes, one call site, because the whole /// point is that the two runs report the same grid. #[cfg(not(feature = "count"))] fn measure(mut render: impl FnMut()) -> String { for _ in 0..WARMUP { render(); } let mut best = u128::MAX; for _ in 0..PASSES { let start = Instant::now(); for _ in 0..ITERATIONS { render(); } best = best.min(start.elapsed().as_nanos() / u128::from(ITERATIONS)); } format!("{best}") } /// One cell of the table, counting rather than timing. /// /// One render, not a loop. An allocation count is exact, so averaging it over /// 20,000 iterations would divide a number by itself and add rounding. #[cfg(feature = "count")] fn measure(mut render: impl FnMut()) -> String { // One untimed render first: the first call through a renderer populates // whatever it lazily builds, and counting that would charge the screen for // a cost the second request does not pay. render(); let (blocks_before, bytes_before) = counting::read(); render(); let (blocks_after, bytes_after) = counting::read(); format!( "{} / {}", blocks_after - blocks_before, bytes_after - bytes_before ) } /// The residual must produce what the renderer produces. /// /// Byte-identity is the right assertion *here* and nowhere else in this design: /// it is not an acceptance test for staging, it is the check that the emitter /// dropped nothing. The staged path wins by deleting the tree, not by changing /// the markup, so at this stage the bytes should still agree. Every further /// optimisation available to an emitter (fusing adjacent literals, hoisting /// per-row constants, dropping wrappers whose region name is resolved at emit /// time) changes the bytes on purpose, and this assertion is what would be /// relaxed to take them. fn check_staged_matches(webview: &Webview, program: &[staging::Op]) { for size in fixture::SIZES { let rows = fixture::Rows::new(size); let direct = webview.fragment(&fixture::pane(&rows)); let mut staged = String::new(); staging::exec(program, &rows, &mut staged); assert_eq!( direct.len(), staged.len(), "staged output differs in length at {size} rows" ); assert!( direct == staged, "staged output differs from the renderer at {size} rows" ); } println!("residual verified against the renderer at every row count\n"); } /// The heading of the sixth column, which depends on what was compiled in. #[cfg(feature = "askama")] const COMPILED_HEAD: &str = "askama"; #[cfg(not(feature = "askama"))] const COMPILED_HEAD: &str = "webview screen"; /// The generated template, rendered. #[cfg(feature = "askama")] fn measure_compiled(_webview: &Webview, rows: &fixture::Rows, _screen: &str) -> String { use askama::Template as _; measure(|| { let page = compiled::Contacts::new(black_box(rows)); black_box(page.render().expect("the generated template renders")); }) } /// Without the feature this column stays what it was: the whole-document cost. #[cfg(not(feature = "askama"))] fn measure_compiled(_webview: &Webview, _rows: &fixture::Rows, screen: &str) -> String { screen.to_string() }