max / quasi
5 files changed,
+248 insertions,
-18 deletions
| @@ -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__/ |
| @@ -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 | + | } |