Skip to main content

max / quasi

Give the immediate renderer a number quasi-bench went two renderers wide because `Immediate::screen` takes a `&mut Ui` and a `Ui` only exists inside a frame egui is running. `immediate` stands one up: a real `Context` over a fixed 1280x900 viewport, drawing the fixture in a central panel. Context, `View` and `Screen` are hoisted out of the loop for the same reason the terminal column hoists its `Buffer`. A fresh `Context` per iteration measures the font atlas, which is a cost a host pays once at launch, and an immediate-mode host redraws a description it already has rather than rebuilding one per frame. The palette resolves the same bundled `goingson` theme the terminal renderer loads, through makeover's own rgb/rgba accessors, so the two renderers draw one theme rather than two. WHAT IT SAYS. Allocations per frame, 5 to 200 rows: immediate 339 -> 1186, webview 234 -> 6869. Past 25 rows, where the window fills, eight times the data buys the immediate renderer 22% more allocation and 27% more time. That is the measured trigger decision 2cff4a3d deferred the compiled path to, and it does not fire: compiling for this renderer would attack a cost that does not scale with the rows. The webview's 29x is where compiling pays. Two corrections the measurement forced, both in the header: - This header predicted the terminal column would be nearly flat. In nanoseconds it is, at 1.9x for 40x the rows; in allocations it is not, at 6.4x, and allocations are the half this bench says to read. - The terminal cell rebuilds the description every iteration and this one does not, which is `fixture::pane`'s rule meeting a host with no requests. The header says so and gives the subtraction rather than restating a number that has been read before.
Author: Max Johnson <me@maxj.phd> · 2026-09-03 12:57 UTC
Signed with PGP, not checked
Commit: f6edde4f967e19ade43eee72caa568ac20069931
Parent: 9667dc2
5 files changed, +248 insertions, -18 deletions
M .gitignore +3
@@ -20,3 +20,6 @@
20 20 *.p12
21 21 *.pfx
22 22 credentials.json
23 +
24 + # Python bytecode, from the measurement scripts under `scripts/`.
25 + __pycache__/
M Cargo.lock +7 -4
@@ -3508,11 +3508,14 @@
3508 3508 dependencies = [
3509 3509 "askama",
3510 3510 "dhat",
3511 + "egui",
3511 3512 "makeover",
3513 + "makeover-immediate",
3512 3514 "makeover-layout",
3513 3515 "makeover-tui",
3514 3516 "makeover-webview",
3515 3517 "quasi-http",
3518 + "quasi-immediate",
3516 3519 "quasi-router",
3517 3520 "quasi-tui",
3518 3521 "quasi-webview",
@@ -6283,10 +6286,10 @@
6283 6286 name = "tagtree"
6284 6287 version = "0.4.1"
6285 6288
6286 - [[patch.unused]]
6287 - name = "synckit-client"
6288 - version = "0.10.0"
6289 -
6290 6289 [[patch.unused]]
6291 6290 name = "quasi-type"
6292 6291 version = "0.1.3"
6292 +
6293 + [[patch.unused]]
6294 + name = "synckit-client"
6295 + version = "0.10.0"
@@ -29,6 +29,7 @@
29 29 quasi-router = { path = "../quasi-router", version = "0.101.0" }
30 30 quasi-webview = { path = "../quasi-webview", version = "0.101.0" }
31 31 quasi-tui = { path = "../quasi-tui", version = "0.101.0" }
32 + quasi-immediate = { path = "../quasi-immediate", version = "0.101.0" }
32 33 # `Serves` is the trait carrying `screen` and `fragment`, which is what the
33 34 # webview is measured through. Taken directly rather than through quasi-webview
34 35 # because a bench calling a trait method should name the trait it calls.
@@ -43,6 +44,11 @@
43 44 # tests take this.
44 45 makeover = "3.1"
45 46 ratatui = { version = "0.30", default-features = false }
47 + # The immediate renderer's frame, and its palette type. `default_fonts` is not
48 + # optional here: a `Context` with no font builds no galley, so a frame drawn
49 + # without them measures layout over nothing.
50 + egui = { version = "0.35", features = ["default_fonts"] }
51 + makeover-immediate = "0.45"
46 52 # Allocation counting, and only that. Optional and off by default. It is here
47 53 # rather than a hand-written `GlobalAlloc` because the workspace forbids
48 54 # `unsafe_code` and `forbid` cannot be overridden per crate; see
@@ -27,18 +27,69 @@
27 27 //! webview's number to the terminal's is comparing a string builder to a cell
28 28 //! painter and means very little.
29 29 //!
30 - //! # Which renderers, and why not the third
30 + //! # Which renderers, and what each column's unit is
31 31 //!
32 - //! Webview and terminal. `quasi-immediate` is absent and not forgotten: its
33 - //! entry point is `Immediate::screen(&self, ui: &mut Ui, ..)`, which needs a
34 - //! live egui `Ui`, so measuring it means standing up an egui harness rather
35 - //! than calling a function. That is worth doing and it is not this; filed
36 - //! rather than silently skipped.
32 + //! All three: webview, terminal, immediate. The last arrived with `immediate`,
33 + //! which stands up a real egui `Context` and runs a frame through it, because
34 + //! `Immediate::screen(&self, ui: &mut Ui, ..)` needs a live `Ui` and a `Ui` is
35 + //! only handed out inside a frame egui is running.
37 36 //!
38 - //! The webview is measured twice, as a fragment and as a whole screen, because
39 - //! those are the two things a route actually answers and the shell is not free.
37 + //! The three units are not the same thing and the table does not pretend they
38 + //! are:
39 + //!
40 + //! - **webview** emits every row into a string, so its cost rises with the
41 + //! rows. It is measured twice, as a fragment and as a whole screen, because
42 + //! those are the two things a route answers and the shell is not free.
43 + //! - **terminal** and **immediate** draw one screenful and leave the rest to
44 + //! scroll state, so neither *has* to pay per row past the viewport.
45 + //!
46 + //! Measured on fw13, 2026-09-03. Growth is 5 rows to 200, which is 40x the
47 + //! data:
48 + //!
49 + //! ```text
50 + //! allocations per frame nanoseconds per frame
51 + //! rows terminal immediate webview terminal immediate webview
52 + //! 5 2475 339 234 326596 56211 7407
53 + //! 25 4250 973 916 434859 192385 30380
54 + //! 200 15815 1186 6869 605476 243559 215603
55 + //! growth 6.4x 3.5x 29.4x 1.9x 4.3x 29.1x
56 + //! ```
57 + //!
58 + //! **The two halves disagree about the terminal, and the allocation half is
59 + //! the one this bench believes.** In time the terminal column is nearly flat,
60 + //! which is what this header predicted; in allocations it is not, and it
61 + //! allocates 13x what the immediate renderer does at 200 rows. So the
62 + //! prediction was right about the number it is cheapest to look at and wrong
63 + //! about the number it says to read.
64 + //!
65 + //! The immediate column is the one that actually flattens where the window
66 + //! fills: past 25 rows, eight times the data buys 22% more allocation and 27%
67 + //! more time. That is the shape a clipping renderer is supposed to have, and
68 + //! it is what says a compiled path would be attacking a cost that does not
69 + //! scale here. The webview, at 29x on both halves, is where compiling pays.
70 + //!
71 + //! Two asymmetries to hold before reading those two columns against each other.
72 + //!
73 + //! 1. **Entry point.** The terminal is measured through `Tui::node`, over the
74 + //! pane alone; the immediate renderer has no node-level entry point, so it
75 + //! goes through `Immediate::screen`, over the pane inside a screen. The
76 + //! immediate number carries the screen walk (notices, regions, the frame)
77 + //! and the terminal number does not.
78 + //! 2. **The rebuild.** The terminal cell rebuilds the description every
79 + //! iteration and the immediate cell does not, which is `fixture::pane`'s
80 + //! stated rule meeting a host that has no requests. Subtract the `build
81 + //! tree` column to compare the draws: at 200 rows that is 15815 - 5418 =
82 + //! 10397 against 1186. The rule was written for the webview, where a request
83 + //! really does pay for the tree; a terminal host redraws a description it
84 + //! already has, the same way an immediate one does, so the terminal column
85 + //! is arguably charging for something too. Left as it is rather than
86 + //! silently restated, because it is a number that has been read before.
87 + //!
88 + //! Both columns still answer the question this bench exists for, which is what
89 + //! a row costs, and that is read down a column.
40 90
41 91 mod fixture;
92 + mod immediate;
42 93 mod staging;
43 94
44 95 #[cfg(feature = "askama")]
@@ -90,9 +141,13 @@
90 141 /// screen no terminal ever draws, and the `Buffer::empty`/`reset` cost of a
91 142 /// 400-row viewport swamped everything else when this bench first ran.
92 143 ///
93 - /// So read the terminal column as cost per frame, and expect it to be nearly
94 - /// flat across the row counts. Where it is not flat is the interesting part:
95 - /// that is the description being walked before the drawing is clipped.
144 + /// So read the terminal column as cost per frame. This said to expect it nearly
145 + /// flat across the row counts, and in nanoseconds it is: 1.9x for 40x the rows.
146 + /// In allocations it is not, at 6.4x, and allocations are what this bench says
147 + /// to read. Both numbers are in the header. What the gap between them means is
148 + /// that the terminal's per-row cost is real and is being paid somewhere the
149 + /// clock does not see it, which is the description being walked before the
150 + /// drawing is clipped.
96 151 const TERMINAL: Rect = Rect {
97 152 x: 0,
98 153 y: 0,
@@ -103,6 +158,11 @@
103 158 fn main() {
104 159 let tui = terminal_renderer();
105 160 let webview = Webview::new();
161 + // The egui context is built once and reused for every frame of every row
162 + // count, which is what a host does and what keeps the font atlas out of
163 + // the numbers. See `immediate`'s header.
164 + let ctx = immediate::context();
165 + let egui_renderer = immediate::renderer();
106 166
107 167 #[cfg(feature = "count")]
108 168 let _session = counting::Session::start();
@@ -113,8 +173,15 @@
113 173 println!("nanoseconds per render, best of {PASSES} passes of {ITERATIONS}\n");
114 174
115 175 println!(
116 - "{:>5} {:>13} {:>13} {:>13} {:>13} {:>13} {:>13}",
117 - "rows", "build tree", "render tree", "build+render", "staged", COMPILED_HEAD, "terminal"
176 + "{:>5} {:>13} {:>13} {:>13} {:>13} {:>13} {:>13} {:>13}",
177 + "rows",
178 + "build tree",
179 + "render tree",
180 + "build+render",
181 + "staged",
182 + COMPILED_HEAD,
183 + "terminal",
184 + "immediate"
118 185 );
119 186
120 187 // The residual, emitted once. A build-time artifact in a real host; emitted
@@ -176,10 +243,20 @@
176 243 black_box(&buf);
177 244 });
178 245
246 + // The screen and the view are hoisted for the buffer's reason, one
247 + // step further: an immediate-mode host redraws a screen it already
248 + // has, and the view is what remembers which row is current between
249 + // frames. `immediate::frame` says why in full.
250 + let drawn = as_screen(fixture::pane(&rows));
251 + let mut view = quasi_immediate::View::new();
252 + let immediate = measure(|| {
253 + immediate::frame(&ctx, &egui_renderer, black_box(&drawn), &mut view);
254 + });
255 +
179 256 let compiled = measure_compiled(&webview, &rows, &screen);
180 257
181 258 println!(
182 - "{size:>5} {build:>13} {render:>13} {fragment:>13} {staged:>13} {compiled:>13} {terminal:>13}"
259 + "{size:>5} {build:>13} {render:>13} {fragment:>13} {staged:>13} {compiled:>13} {terminal:>13} {immediate:>13}"
183 260 );
184 261 }
185 262
@@ -1,0 +1,141 @@
1 + //! The egui harness, so the immediate renderer has a number.
2 + //!
3 + //! [`quasi_immediate::Immediate::screen`] takes a `&mut Ui`, and a `Ui` is not
4 + //! constructible on its own: egui hands one out inside a frame it is running.
5 + //! So measuring this renderer means standing up a frame rather than calling a
6 + //! function, which is why the bench went two renderers wide for as long as it
7 + //! did.
8 + //!
9 + //! # What a cell in this column is
10 + //!
11 + //! One frame: a `Context` run over a fixed viewport with no input, drawing the
12 + //! fixture inside a central panel. That is the terminal column's unit and not
13 + //! the webview's -- a webview emits every row into a string the browser
14 + //! scrolls, and both of the other two draw one screenful and leave the rest to
15 + //! scroll state. Expect it to be flatter across the row counts than the webview
16 + //! is, and read the places where it is not: that is the description being
17 + //! walked before the drawing is clipped.
18 + //!
19 + //! # Three things hoisted out of the loop, each for the buffer's reason
20 + //!
21 + //! The terminal column hoists its `Buffer` because `Buffer::empty` for a 160x50
22 + //! viewport swamped the drawing when this bench first ran. The same argument
23 + //! applies three times here, and it is stronger, because egui's per-frame state
24 + //! is larger than a cell grid.
25 + //!
26 + //! - **The `Context`.** It owns the font atlas, the texture allocations and the
27 + //! memory carried between frames. A fresh one per iteration measures egui
28 + //! starting up, which is a cost a host pays once at launch.
29 + //! - **The `View`.** A host owns one across frames; it is what remembers which
30 + //! row is current and what is open.
31 + //! - **The `Screen`.** Unlike the other columns, which rebuild the description
32 + //! per iteration on purpose. See [`frame`]: this column cannot make that
33 + //! choice honestly, and says so rather than quietly measuring something else.
34 + //!
35 + //! # The palette is the shipped theme, not an invented one
36 + //!
37 + //! [`palette`] resolves the same bundled `goingson` theme the terminal renderer
38 + //! loads, through `makeover::resolve`'s own `rgb`/`rgba` accessors. A literal
39 + //! would be sixteen colours this file made up, and the two renderers would then
40 + //! be drawing different themes in the same table.
41 +
42 + use egui::{Color32, Context, Pos2, RawInput, Rect, vec2};
43 + use makeover_immediate::Palette;
44 + use quasi_immediate::{Immediate, View};
45 + use quasi_router::Screen;
46 +
47 + /// The window the frame is drawn in.
48 + ///
49 + /// The terminal's `TERMINAL` and its reasoning, in points: a real window rather
50 + /// than one sized to the content, because sizing it to the content measures a
51 + /// window nobody has and charges the renderer for rows no reader can see.
52 + const WINDOW: (f32, f32) = (1280.0, 900.0);
53 +
54 + /// A renderer in the shipped theme.
55 + #[must_use]
56 + pub fn renderer() -> Immediate {
57 + Immediate::new(palette())
58 + }
59 +
60 + /// A context sized to [`WINDOW`], with fonts already built.
61 + ///
62 + /// The caller holds it across every iteration. Building one costs the font
63 + /// atlas, which is the single largest allocation in an egui program and is paid
64 + /// once at launch by any real host.
65 + #[must_use]
66 + pub fn context() -> Context {
67 + Context::default()
68 + }
69 +
70 + /// One frame.
71 + ///
72 + /// The screen is borrowed rather than built here, which is the one place this
73 + /// column departs from the others and it is deliberate rather than convenient.
74 + /// The webview and terminal columns rebuild the description per iteration
75 + /// because a request pays for it; an immediate-mode host does not have
76 + /// requests. It draws sixty frames a second over a description it built when
77 + /// the screen changed, so charging every frame for the build would report a
78 + /// cost the app does not have.
79 + ///
80 + /// The build cost is not lost: it is the `build tree` column, which is the same
81 + /// number for all three renderers.
82 + pub fn frame(ctx: &Context, immediate: &Immediate, screen: &Screen, view: &mut View) {
83 + let input = RawInput {
84 + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(WINDOW.0, WINDOW.1))),
85 + ..Default::default()
86 + };
87 + let output = ctx.run_ui(input, |ctx| {
88 + egui::CentralPanel::default().show(ctx, |ui| {
89 + immediate.screen(ui, screen, view);
90 + });
91 + });
92 + // Consumed so the tessellation is not dead-code-eliminated, and dropped
93 + // rather than returned: a host uploads these to the GPU, which is not this
94 + // crate's cost and not measurable without one.
95 + std::hint::black_box(&output.shapes);
96 + }
97 +
98 + /// The shipped `goingson` theme, as an immediate-mode palette.
99 + ///
100 + /// Every one of the sixteen fields comes from a resolved token. `overlay` and
101 + /// `elevation` are the two intents makeover emits as `rgba(...)` rather than
102 + /// hex, because they are scrims, so they are read through `rgba` and the rest
103 + /// through `rgb`. Reading a scrim as an opaque colour is how a translucent
104 + /// token becomes a near-black rectangle over the page.
105 + fn palette() -> Palette {
106 + let dir = makeover::bundled_themes_dir().expect("makeover ships themes");
107 + let colours = makeover::load_theme(&[(dir, false)], "goingson").expect("a bundled theme loads");
108 + let tokens = makeover::resolve(&colours);
109 +
110 + let opaque = |key: &str| {
111 + let (r, g, b) = tokens
112 + .rgb(key)
113 + .unwrap_or_else(|| panic!("the resolved theme carries `{key}`"));
114 + Color32::from_rgb(r, g, b)
115 + };
116 + let scrim = |key: &str| {
117 + let (r, g, b, a) = tokens
118 + .rgba(key)
119 + .unwrap_or_else(|| panic!("the resolved theme carries `{key}`"));
120 + Color32::from_rgba_unmultiplied(r, g, b, a)
121 + };
122 +
123 + Palette {
124 + page: opaque("surface-page"),
125 + raised: opaque("surface-raised"),
126 + overlay: scrim("overlay"),
127 + well: opaque("surface-well"),
128 + sunken: opaque("surface-sunken"),
129 + bevel_light: opaque("bevel-light"),
130 + bevel_dark: opaque("bevel-dark"),
131 + elevation: scrim("elevation"),
132 + content: opaque("content"),
133 + content_secondary: opaque("content-secondary"),
134 + content_muted: opaque("content-muted"),
135 + action: opaque("action"),
136 + danger: opaque("danger"),
137 + success: opaque("success"),
138 + warning: opaque("warning"),
139 + info: opaque("info"),
140 + }
141 + }