max / quasi
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+651 insertions,
-9 deletions
| @@ -11,6 +11,15 @@ | |||
| 11 | 11 | "uuid", | |
| 12 | 12 | ] | |
| 13 | 13 | ||
| 14 | + | [[package]] | |
| 15 | + | name = "addr2line" | |
| 16 | + | version = "0.25.1" | |
| 17 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 18 | + | checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" | |
| 19 | + | dependencies = [ | |
| 20 | + | "gimli", | |
| 21 | + | ] | |
| 22 | + | ||
| 14 | 23 | [[package]] | |
| 15 | 24 | name = "adler2" | |
| 16 | 25 | version = "2.0.1" | |
| @@ -179,6 +188,21 @@ | |||
| 179 | 188 | "tracing", | |
| 180 | 189 | ] | |
| 181 | 190 | ||
| 191 | + | [[package]] | |
| 192 | + | name = "backtrace" | |
| 193 | + | version = "0.3.76" | |
| 194 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 195 | + | checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" | |
| 196 | + | dependencies = [ | |
| 197 | + | "addr2line", | |
| 198 | + | "cfg-if", | |
| 199 | + | "libc", | |
| 200 | + | "miniz_oxide", | |
| 201 | + | "object", | |
| 202 | + | "rustc-demangle", | |
| 203 | + | "windows-link 0.2.1", | |
| 204 | + | ] | |
| 205 | + | ||
| 182 | 206 | [[package]] | |
| 183 | 207 | name = "base64" | |
| 184 | 208 | version = "0.21.7" | |
| @@ -757,6 +781,22 @@ | |||
| 757 | 781 | "syn 2.0.119", | |
| 758 | 782 | ] | |
| 759 | 783 | ||
| 784 | + | [[package]] | |
| 785 | + | name = "dhat" | |
| 786 | + | version = "0.3.3" | |
| 787 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 788 | + | checksum = "98cd11d84628e233de0ce467de10b8633f4ddaecafadefc86e13b84b8739b827" | |
| 789 | + | dependencies = [ | |
| 790 | + | "backtrace", | |
| 791 | + | "lazy_static", | |
| 792 | + | "mintex", | |
| 793 | + | "parking_lot", | |
| 794 | + | "rustc-hash 1.1.0", | |
| 795 | + | "serde", | |
| 796 | + | "serde_json", | |
| 797 | + | "thousands", | |
| 798 | + | ] | |
| 799 | + | ||
| 760 | 800 | [[package]] | |
| 761 | 801 | name = "digest" | |
| 762 | 802 | version = "0.10.7" | |
| @@ -1391,6 +1431,12 @@ | |||
| 1391 | 1431 | "r-efi 6.0.0", | |
| 1392 | 1432 | ] | |
| 1393 | 1433 | ||
| 1434 | + | [[package]] | |
| 1435 | + | name = "gimli" | |
| 1436 | + | version = "0.32.3" | |
| 1437 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1438 | + | checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" | |
| 1439 | + | ||
| 1394 | 1440 | [[package]] | |
| 1395 | 1441 | name = "gio" | |
| 1396 | 1442 | version = "0.18.4" | |
| @@ -2163,6 +2209,12 @@ | |||
| 2163 | 2209 | "smallvec", | |
| 2164 | 2210 | ] | |
| 2165 | 2211 | ||
| 2212 | + | [[package]] | |
| 2213 | + | name = "lazy_static" | |
| 2214 | + | version = "1.5.0" | |
| 2215 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2216 | + | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" | |
| 2217 | + | ||
| 2166 | 2218 | [[package]] | |
| 2167 | 2219 | name = "libappindicator" | |
| 2168 | 2220 | version = "0.9.0" | |
| @@ -2408,6 +2460,12 @@ | |||
| 2408 | 2460 | "simd-adler32", | |
| 2409 | 2461 | ] | |
| 2410 | 2462 | ||
| 2463 | + | [[package]] | |
| 2464 | + | name = "mintex" | |
| 2465 | + | version = "0.1.4" | |
| 2466 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2467 | + | checksum = "c505b3e17ed6b70a7ed2e67fbb2c560ee327353556120d6e72f5232b6880d536" | |
| 2468 | + | ||
| 2411 | 2469 | [[package]] | |
| 2412 | 2470 | name = "mio" | |
| 2413 | 2471 | version = "1.2.2" | |
| @@ -2708,6 +2766,15 @@ | |||
| 2708 | 2766 | "objc2-foundation", | |
| 2709 | 2767 | ] | |
| 2710 | 2768 | ||
| 2769 | + | [[package]] | |
| 2770 | + | name = "object" | |
| 2771 | + | version = "0.37.3" | |
| 2772 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2773 | + | checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" | |
| 2774 | + | dependencies = [ | |
| 2775 | + | "memchr", | |
| 2776 | + | ] | |
| 2777 | + | ||
| 2711 | 2778 | [[package]] | |
| 2712 | 2779 | name = "once_cell" | |
| 2713 | 2780 | version = "1.21.4" | |
| @@ -3051,6 +3118,21 @@ | |||
| 3051 | 3118 | "quasi-webview", | |
| 3052 | 3119 | ] | |
| 3053 | 3120 | ||
| 3121 | + | [[package]] | |
| 3122 | + | name = "quasi-bench" | |
| 3123 | + | version = "0.8.0" | |
| 3124 | + | dependencies = [ | |
| 3125 | + | "dhat", | |
| 3126 | + | "makeover", | |
| 3127 | + | "makeover-layout", | |
| 3128 | + | "makeover-tui", | |
| 3129 | + | "quasi-http", | |
| 3130 | + | "quasi-router", | |
| 3131 | + | "quasi-tui", | |
| 3132 | + | "quasi-webview", | |
| 3133 | + | "ratatui", | |
| 3134 | + | ] | |
| 3135 | + | ||
| 3054 | 3136 | [[package]] | |
| 3055 | 3137 | name = "quasi-http" | |
| 3056 | 3138 | version = "0.8.0" | |
| @@ -3343,6 +3425,18 @@ | |||
| 3343 | 3425 | "sqlite-wasm-rs", | |
| 3344 | 3426 | ] | |
| 3345 | 3427 | ||
| 3428 | + | [[package]] | |
| 3429 | + | name = "rustc-demangle" | |
| 3430 | + | version = "0.1.28" | |
| 3431 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3432 | + | checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" | |
| 3433 | + | ||
| 3434 | + | [[package]] | |
| 3435 | + | name = "rustc-hash" | |
| 3436 | + | version = "1.1.0" | |
| 3437 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3438 | + | checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" | |
| 3439 | + | ||
| 3346 | 3440 | [[package]] | |
| 3347 | 3441 | name = "rustc-hash" | |
| 3348 | 3442 | version = "2.1.3" | |
| @@ -3450,7 +3544,7 @@ | |||
| 3450 | 3544 | "phf", | |
| 3451 | 3545 | "phf_codegen", | |
| 3452 | 3546 | "precomputed-hash", | |
| 3453 | - | "rustc-hash", | |
| 3547 | + | "rustc-hash 2.1.3", | |
| 3454 | 3548 | "servo_arc", | |
| 3455 | 3549 | "smallvec", | |
| 3456 | 3550 | ] | |
| @@ -4232,6 +4326,12 @@ | |||
| 4232 | 4326 | "syn 3.0.3", | |
| 4233 | 4327 | ] | |
| 4234 | 4328 | ||
| 4329 | + | [[package]] | |
| 4330 | + | name = "thousands" | |
| 4331 | + | version = "0.2.0" | |
| 4332 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4333 | + | checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820" | |
| 4334 | + | ||
| 4235 | 4335 | [[package]] | |
| 4236 | 4336 | name = "time" | |
| 4237 | 4337 | version = "0.3.55" | |
| @@ -5524,6 +5624,14 @@ | |||
| 5524 | 5624 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5525 | 5625 | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" | |
| 5526 | 5626 | ||
| 5627 | + | [[patch.unused]] | |
| 5628 | + | name = "synckit-client" | |
| 5629 | + | version = "0.8.0" | |
| 5630 | + | ||
| 5631 | + | [[patch.unused]] | |
| 5632 | + | name = "synckit-config" | |
| 5633 | + | version = "0.2.0" | |
| 5634 | + | ||
| 5527 | 5635 | [[patch.unused]] | |
| 5528 | 5636 | name = "kberg" | |
| 5529 | 5637 | version = "0.1.0" | |
| @@ -5539,11 +5647,3 @@ | |||
| 5539 | 5647 | [[patch.unused]] | |
| 5540 | 5648 | name = "tagtree" | |
| 5541 | 5649 | version = "0.4.0" | |
| 5542 | - | ||
| 5543 | - | [[patch.unused]] | |
| 5544 | - | name = "synckit-client" | |
| 5545 | - | version = "0.8.0" | |
| 5546 | - | ||
| 5547 | - | [[patch.unused]] | |
| 5548 | - | name = "synckit-config" | |
| 5549 | - | version = "0.2.0" |
| @@ -4,6 +4,7 @@ | |||
| 4 | 4 | "crates/quasi", | |
| 5 | 5 | "crates/quasi-axum", | |
| 6 | 6 | "crates/quasi-basics", | |
| 7 | + | "crates/quasi-bench", | |
| 7 | 8 | "crates/quasi-http", | |
| 8 | 9 | "crates/quasi-router", | |
| 9 | 10 | "crates/quasi-store", |
| @@ -1,0 +1,43 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "quasi-bench" | |
| 3 | + | version = "0.8.0" | |
| 4 | + | description = "What a described screen costs to render, in time and in allocations" | |
| 5 | + | edition.workspace = true | |
| 6 | + | rust-version.workspace = true | |
| 7 | + | authors.workspace = true | |
| 8 | + | repository.workspace = true | |
| 9 | + | license.workspace = true | |
| 10 | + | publish = false | |
| 11 | + | ||
| 12 | + | [lints] | |
| 13 | + | workspace = true | |
| 14 | + | ||
| 15 | + | [features] | |
| 16 | + | # Allocation counting, off by default and never on in a timing run. | |
| 17 | + | # | |
| 18 | + | # A counting allocator is real instrumentation, and a timing number measured | |
| 19 | + | # through it is a number about the instrumentation. So the two measurements are | |
| 20 | + | # two builds: `cargo run --release -p quasi-bench` times, and | |
| 21 | + | # `cargo run --release -p quasi-bench --features count` counts. | |
| 22 | + | count = ["dep:dhat"] | |
| 23 | + | ||
| 24 | + | [dependencies] | |
| 25 | + | quasi-router = { path = "../quasi-router", version = "0.8.0" } | |
| 26 | + | quasi-webview = { path = "../quasi-webview", version = "0.8.0" } | |
| 27 | + | quasi-tui = { path = "../quasi-tui", version = "0.8.0" } | |
| 28 | + | # `Serves` is the trait carrying `screen` and `fragment`, which is what the | |
| 29 | + | # webview is measured through. Taken directly rather than through quasi-webview | |
| 30 | + | # because a bench calling a trait method should name the trait it calls. | |
| 31 | + | quasi-http = { path = "../quasi-http", version = "0.8.0" } | |
| 32 | + | makeover-layout = "0.26.1" | |
| 33 | + | makeover-tui = { version = "0.24.0", features = ["theme"] } | |
| 34 | + | # Only to load a bundled theme file. `makeover_tui::Theme` is `#[non_exhaustive]`, | |
| 35 | + | # so `Theme::from_theme` is the one way to get one, the same reason quasi-tui's | |
| 36 | + | # tests take this. | |
| 37 | + | makeover = "2.5" | |
| 38 | + | ratatui = { version = "0.30", default-features = false } | |
| 39 | + | # Allocation counting, and only that. Optional and off by default. It is here | |
| 40 | + | # rather than a hand-written `GlobalAlloc` because the workspace forbids | |
| 41 | + | # `unsafe_code` and `forbid` cannot be overridden per crate; see | |
| 42 | + | # `src/counting.rs`. | |
| 43 | + | dhat = { version = "0.3", optional = true } |
| @@ -1,0 +1,57 @@ | |||
| 1 | + | # quasi-bench | |
| 2 | + | ||
| 3 | + | What a described screen costs to render, in time and in allocations. | |
| 4 | + | ||
| 5 | + | ``` | |
| 6 | + | cargo run --release -p quasi-bench # nanoseconds per render | |
| 7 | + | cargo run --release -p quasi-bench --features count # allocations per render | |
| 8 | + | ``` | |
| 9 | + | ||
| 10 | + | Two builds, because the counting allocator is real instrumentation and a timing | |
| 11 | + | number taken through it is a number about the instrumentation. | |
| 12 | + | ||
| 13 | + | Read the allocation count first. It is stable across machines and across | |
| 14 | + | whatever else the box is doing; the timing is a sanity check beside it, and on a | |
| 15 | + | shared dev box a 10% difference in it is noise. | |
| 16 | + | ||
| 17 | + | Read down the row-count column rather than across renderers. A cost flat across | |
| 18 | + | 5, 25 and 200 rows is fixed and a cost that tracks them is per-row. Comparing | |
| 19 | + | the webview's number to the terminal's compares a string builder to a cell | |
| 20 | + | painter, and the two columns do not mean the same thing: the webview emits every | |
| 21 | + | row, the terminal draws one 160x50 frame and the rest is scroll state. | |
| 22 | + | ||
| 23 | + | `quasi-immediate` is absent. Its entry point needs a live egui `Ui`, so | |
| 24 | + | measuring it means standing up an egui harness rather than calling a function. | |
| 25 | + | ||
| 26 | + | ## Baseline, fw13, 2026-08-15 | |
| 27 | + | ||
| 28 | + | The screen is a copy of MNW's `library_contacts` pane: two tables, five columns | |
| 29 | + | and two, linked values in two flavours, a destructive per-row act. | |
| 30 | + | ||
| 31 | + | Allocations per render (allocs / bytes): | |
| 32 | + | ||
| 33 | + | | rows | webview fragment | webview screen | terminal | | |
| 34 | + | |------|------------------|----------------|----------| | |
| 35 | + | | 5 | 205 / 37,890 | 207 / 38,734 | 2,375 / 357,997 | | |
| 36 | + | | 25 | 807 / 146,984 | 809 / 147,828 | 3,988 / 506,255 | | |
| 37 | + | | 200 | 6,060 / 1,130,426 | 6,062 / 1,131,270 | 12,576 / 1,230,646 | | |
| 38 | + | ||
| 39 | + | Nanoseconds per render, best of 5 passes of 20,000: | |
| 40 | + | ||
| 41 | + | | rows | webview fragment | webview screen | terminal | | |
| 42 | + | |------|------------------|----------------|----------| | |
| 43 | + | | 5 | 6,171 | 6,693 | 299,376 | | |
| 44 | + | | 25 | 28,026 | 30,225 | 401,624 | | |
| 45 | + | | 200 | 204,136 | 206,474 | 497,650 | | |
| 46 | + | ||
| 47 | + | Two things worth knowing before reading these as a verdict on anything. | |
| 48 | + | ||
| 49 | + | The shell is nearly free. A whole screen costs two allocations more than the | |
| 50 | + | fragment inside it, at every size, so the head and the stylesheet links are not | |
| 51 | + | where a page load goes. | |
| 52 | + | ||
| 53 | + | The terminal is the expensive column and its cost is mostly not per-row. 2,375 | |
| 54 | + | allocations to draw a five-row screen against the webview's 205, rising only to | |
| 55 | + | 12,576 at 200 rows where the webview reaches 6,060. Sublinear because the frame | |
| 56 | + | clips, and high at the bottom end for a reason nothing here diagnoses. That is | |
| 57 | + | the bench doing its job; the diagnosis is separate work. |
| @@ -1,0 +1,50 @@ | |||
| 1 | + | //! Allocation counting, compiled in only under `--features count`. | |
| 2 | + | //! | |
| 3 | + | //! The count is the number that matters. It is stable across machines and | |
| 4 | + | //! across load, which a wall-clock number on a shared dev box is not, and it is | |
| 5 | + | //! what an emitter change actually moves. The timing run answers "is this | |
| 6 | + | //! faster here today"; this one answers "does this do less work". | |
| 7 | + | //! | |
| 8 | + | //! # Why `dhat` rather than a hand-written `GlobalAlloc` | |
| 9 | + | //! | |
| 10 | + | //! The workspace sets `unsafe_code = "forbid"`, and a `GlobalAlloc` | |
| 11 | + | //! implementation cannot be written without `unsafe`. `forbid` is not | |
| 12 | + | //! overridable by an `#[allow]`, so a hand-rolled counter would mean this crate | |
| 13 | + | //! opting out of the workspace lint block, and that block is meant to be | |
| 14 | + | //! identical everywhere. A dependency that is off by default is the cheaper | |
| 15 | + | //! trade: `dhat` holds the `unsafe`, and the bench holds none. | |
| 16 | + | //! | |
| 17 | + | //! It also answers the reason the two measurements are two builds. `dhat`'s | |
| 18 | + | //! allocator is real instrumentation and a timing number taken through it would | |
| 19 | + | //! be a number about the instrumentation, so `count` is never on in a timing | |
| 20 | + | //! run. | |
| 21 | + | ||
| 22 | + | /// The counting allocator, in place only in a `--features count` build. | |
| 23 | + | #[global_allocator] | |
| 24 | + | static ALLOC: dhat::Alloc = dhat::Alloc; | |
| 25 | + | ||
| 26 | + | /// A running profiler, held for as long as measurements are being taken. | |
| 27 | + | pub struct Session(dhat::Profiler); | |
| 28 | + | ||
| 29 | + | impl Session { | |
| 30 | + | /// Start counting. | |
| 31 | + | /// | |
| 32 | + | /// `testing()` keeps `dhat` from writing its JSON output file: the bench | |
| 33 | + | /// wants two integers, not a profile to open in a viewer. | |
| 34 | + | #[must_use] | |
| 35 | + | pub fn start() -> Self { | |
| 36 | + | Self(dhat::Profiler::builder().testing().build()) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// Allocations and bytes so far in this session. | |
| 41 | + | /// | |
| 42 | + | /// `total_blocks` is every allocation since the profiler started, which is what | |
| 43 | + | /// "how many allocations does rendering this screen take" means. The live and | |
| 44 | + | /// peak figures `dhat` also carries are about retention, and a renderer that | |
| 45 | + | /// returns a `String` and drops everything else has nothing interesting there. | |
| 46 | + | #[must_use] | |
| 47 | + | pub fn read() -> (u64, u64) { | |
| 48 | + | let stats = dhat::HeapStats::get(); | |
| 49 | + | (stats.total_blocks, stats.total_bytes) | |
| 50 | + | } |
| @@ -1,0 +1,171 @@ | |||
| 1 | + | //! The screen the bench renders. | |
| 2 | + | //! | |
| 3 | + | //! A copy of MNW's `library_contacts` pane, which is a real converted screen | |
| 4 | + | //! rather than a shape invented for a benchmark. It was picked for the same | |
| 5 | + | //! reason that batch picked it: it holds every table member the vocabulary has | |
| 6 | + | //! grown and nothing that is still unnamed, so a cost measured here is a cost a | |
| 7 | + | //! shipped screen pays. | |
| 8 | + | //! | |
| 9 | + | //! What it deliberately keeps from the original: two tables rather than one, a | |
| 10 | + | //! value that is a link in two flavours (a route this app answers and a | |
| 11 | + | //! `mailto:` that leaves), a destructive per-row act, and column widths and | |
| 12 | + | //! priorities that make the narrowing pass do work. A single-column table of | |
| 13 | + | //! plain strings would measure the loop and not the renderer. | |
| 14 | + | //! | |
| 15 | + | //! The row counts are the bench's axis, not the screen's: see [`SIZES`]. | |
| 16 | + | ||
| 17 | + | use makeover_layout as layout; | |
| 18 | + | use quasi_router::screen::{Act, Cell, Cells, Column}; | |
| 19 | + | use quasi_router::{Action, Node, RegionKind, Slot}; | |
| 20 | + | ||
| 21 | + | /// The region id, matching the original screen's. | |
| 22 | + | const REGION: &str = "tab-content"; | |
| 23 | + | ||
| 24 | + | /// Row counts, and why these three. | |
| 25 | + | /// | |
| 26 | + | /// Three sizes rather than one, because a cost that is flat across them is a | |
| 27 | + | /// fixed cost and a cost that tracks them is per-row, and separating those two | |
| 28 | + | /// is what made the 2026-08-14 reading legible. Any single number hides it. | |
| 29 | + | /// | |
| 30 | + | /// 5 is a screen someone actually has, 25 is a full page, 200 is the size at | |
| 31 | + | /// which a per-row cost stops being deniable. | |
| 32 | + | pub const SIZES: [usize; 3] = [5, 25, 200]; | |
| 33 | + | ||
| 34 | + | /// One buyer row's worth of strings. | |
| 35 | + | struct Buyer { | |
| 36 | + | username: String, | |
| 37 | + | email: String, | |
| 38 | + | purchases: String, | |
| 39 | + | spent: String, | |
| 40 | + | last_purchase: String, | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /// One shared-with row's worth of strings. | |
| 44 | + | struct Shared { | |
| 45 | + | seller_id: String, | |
| 46 | + | username: String, | |
| 47 | + | name: String, | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// Rows built once, outside the timed region. | |
| 51 | + | /// | |
| 52 | + | /// The bench measures rendering, so building the strings must not be inside the | |
| 53 | + | /// loop. They are generated rather than repeated so that no two cells are the | |
| 54 | + | /// same string, which keeps an allocator from making a repeated value look | |
| 55 | + | /// cheaper than a real one. | |
| 56 | + | pub struct Rows { | |
| 57 | + | buyers: Vec<Buyer>, | |
| 58 | + | shared: Vec<Shared>, | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | impl Rows { | |
| 62 | + | /// `count` rows in each of the two tables. | |
| 63 | + | #[must_use] | |
| 64 | + | pub fn new(count: usize) -> Self { | |
| 65 | + | let buyers = (0..count) | |
| 66 | + | .map(|i| Buyer { | |
| 67 | + | username: format!("buyer{i:04}"), | |
| 68 | + | email: format!("buyer{i:04}@example.com"), | |
| 69 | + | purchases: (i % 37 + 1).to_string(), | |
| 70 | + | spent: format!("${}.{:02}", i * 7 % 900 + 3, i % 100), | |
| 71 | + | last_purchase: format!("Aug {:02}, 2026", i % 28 + 1), | |
| 72 | + | }) | |
| 73 | + | .collect(); | |
| 74 | + | let shared = (0..count) | |
| 75 | + | .map(|i| Shared { | |
| 76 | + | seller_id: format!("{:08}", i * 31 + 7), | |
| 77 | + | username: format!("creator{i:04}"), | |
| 78 | + | name: format!("Creator Number {i}"), | |
| 79 | + | }) | |
| 80 | + | .collect(); | |
| 81 | + | Self { buyers, shared } | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// The pane, built the way the real screen builds it. | |
| 86 | + | /// | |
| 87 | + | /// Rebuilt per iteration on purpose. Building the description is part of what a | |
| 88 | + | /// request pays, and a bench that hoisted it out would measure the renderer | |
| 89 | + | /// against a tree the handler never actually has to make. | |
| 90 | + | #[must_use] | |
| 91 | + | pub fn pane(rows: &Rows) -> Node { | |
| 92 | + | let slot = Slot::new(REGION, RegionKind::Pane) | |
| 93 | + | .with(Node::section(format!( | |
| 94 | + | "Your Buyers ({})", | |
| 95 | + | rows.buyers.len() | |
| 96 | + | ))) | |
| 97 | + | .with(Node::text( | |
| 98 | + | "Buyers who opted to share their email with you at purchase time.", | |
| 99 | + | )) | |
| 100 | + | .with(buyers_table(&rows.buyers)) | |
| 101 | + | .with(Node::section("Shared With")) | |
| 102 | + | .with(Node::text( | |
| 103 | + | "You've shared your email with these creators. You can revoke sharing at any time.", | |
| 104 | + | )) | |
| 105 | + | .with(shared_table(&rows.shared)); | |
| 106 | + | ||
| 107 | + | Node::Region(slot) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | /// Five columns, three widths, two priorities, two flavours of linked value. | |
| 111 | + | fn buyers_table(buyers: &[Buyer]) -> Node { | |
| 112 | + | Node::Table { | |
| 113 | + | columns: vec![ | |
| 114 | + | Column::new("Username") | |
| 115 | + | .width(layout::Width::Content) | |
| 116 | + | .priority(layout::Priority::Essential), | |
| 117 | + | Column::new("Email") | |
| 118 | + | .width(layout::Width::Fill) | |
| 119 | + | .priority(layout::Priority::Essential), | |
| 120 | + | Column::new("Purchases").width(layout::Width::Content), | |
| 121 | + | Column::new("Total Spent").width(layout::Width::Content), | |
| 122 | + | Column::new("Last Purchase") | |
| 123 | + | .width(layout::Width::Content) | |
| 124 | + | .priority(layout::Priority::Optional), | |
| 125 | + | ], | |
| 126 | + | rows: buyers | |
| 127 | + | .iter() | |
| 128 | + | .map(|buyer| { | |
| 129 | + | Cells::new([ | |
| 130 | + | Cell::new(buyer.username.clone()) | |
| 131 | + | .activate(Action::get(format!("/u/{}", buyer.username))), | |
| 132 | + | Cell::new(buyer.email.clone()) | |
| 133 | + | .activate(Action::external(format!("mailto:{}", buyer.email))), | |
| 134 | + | Cell::new(buyer.purchases.clone()), | |
| 135 | + | Cell::new(buyer.spent.clone()), | |
| 136 | + | Cell::new(buyer.last_purchase.clone()), | |
| 137 | + | ]) | |
| 138 | + | }) | |
| 139 | + | .collect(), | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /// Two columns, one of them a destructive act. | |
| 144 | + | fn shared_table(shared: &[Shared]) -> Node { | |
| 145 | + | Node::Table { | |
| 146 | + | columns: vec![ | |
| 147 | + | Column::new("Creator") | |
| 148 | + | .width(layout::Width::Fill) | |
| 149 | + | .priority(layout::Priority::Essential), | |
| 150 | + | Column::new("") | |
| 151 | + | .width(layout::Width::Content) | |
| 152 | + | .priority(layout::Priority::Essential), | |
| 153 | + | ], | |
| 154 | + | rows: shared | |
| 155 | + | .iter() | |
| 156 | + | .map(|creator| { | |
| 157 | + | Cells::new([ | |
| 158 | + | Cell::new(creator.name.clone()) | |
| 159 | + | .activate(Action::get(format!("/u/{}", creator.username))), | |
| 160 | + | Cell::acts([Act::new( | |
| 161 | + | "Revoke", | |
| 162 | + | Action::delete(format!( | |
| 163 | + | "/library/tabs/contacts/revoke/{}", | |
| 164 | + | creator.seller_id | |
| 165 | + | )), | |
| 166 | + | )]), | |
| 167 | + | ]) | |
| 168 | + | }) | |
| 169 | + | .collect(), | |
| 170 | + | } | |
| 171 | + | } |
| @@ -1,0 +1,220 @@ | |||
| 1 | + | //! What a described screen costs to render. | |
| 2 | + | //! | |
| 3 | + | //! Four renderers sit on one description layer, so a cost introduced in the | |
| 4 | + | //! description tier is paid by all of them at once. Nothing noticed that until | |
| 5 | + | //! now: the 42x figure in the 2026-08-14 conversion cost report came from a | |
| 6 | + | //! throwaway crate in a scratchpad, which means it cannot be re-read and the | |
| 7 | + | //! next regression would land silently. | |
| 8 | + | //! | |
| 9 | + | //! # Running it | |
| 10 | + | //! | |
| 11 | + | //! Two builds, because the second one instruments the allocator and a timing | |
| 12 | + | //! number taken through instrumentation is a number about the instrumentation. | |
| 13 | + | //! | |
| 14 | + | //! ```text | |
| 15 | + | //! cargo run --release -p quasi-bench # nanoseconds per render | |
| 16 | + | //! cargo run --release -p quasi-bench --features count # allocations per render | |
| 17 | + | //! ``` | |
| 18 | + | //! | |
| 19 | + | //! Release, always. A debug build measures `opt-level = 1` and answers a | |
| 20 | + | //! question nobody asked. | |
| 21 | + | //! | |
| 22 | + | //! # What to read | |
| 23 | + | //! | |
| 24 | + | //! The allocation count, first and mostly. It is stable across machines and | |
| 25 | + | //! across whatever else the box is doing, and it is the thing the emitter work | |
| 26 | + | //! moves. The timing is a sanity check beside it, and on a shared dev box a | |
| 27 | + | //! 10% difference in it is noise. | |
| 28 | + | //! | |
| 29 | + | //! Read down the row-count column rather than across renderers. A cost that is | |
| 30 | + | //! flat across 5, 25 and 200 rows is fixed and a cost that tracks them is | |
| 31 | + | //! per-row, and that distinction is what made the 2026-08-14 reading legible. | |
| 32 | + | //! Comparing the webview's number to the terminal's is comparing a string | |
| 33 | + | //! builder to a cell painter and means very little. | |
| 34 | + | //! | |
| 35 | + | //! # Which renderers, and why not the third | |
| 36 | + | //! | |
| 37 | + | //! Webview and terminal. `quasi-immediate` is absent and not forgotten: its | |
| 38 | + | //! entry point is `Immediate::screen(&self, ui: &mut Ui, ..)`, which needs a | |
| 39 | + | //! live egui `Ui`, so measuring it means standing up an egui harness rather | |
| 40 | + | //! than calling a function. That is worth doing and it is not this; filed | |
| 41 | + | //! rather than silently skipped. | |
| 42 | + | //! | |
| 43 | + | //! The webview is measured twice, as a fragment and as a whole screen, because | |
| 44 | + | //! those are the two things a route actually answers and the shell is not free. | |
| 45 | + | ||
| 46 | + | mod fixture; | |
| 47 | + | ||
| 48 | + | #[cfg(feature = "count")] | |
| 49 | + | mod counting; | |
| 50 | + | ||
| 51 | + | use std::hint::black_box; | |
| 52 | + | #[cfg(not(feature = "count"))] | |
| 53 | + | use std::time::Instant; | |
| 54 | + | ||
| 55 | + | use makeover_layout as layout; | |
| 56 | + | use makeover_tui::{Fidelity, Theme}; | |
| 57 | + | use quasi_http::Serves; | |
| 58 | + | use quasi_router::{Node, RegionKind, Screen, Slot}; | |
| 59 | + | use quasi_tui::{Tui, View}; | |
| 60 | + | use quasi_webview::Webview; | |
| 61 | + | use ratatui::buffer::Buffer; | |
| 62 | + | use ratatui::layout::Rect; | |
| 63 | + | ||
| 64 | + | /// Iterations per pass in a timing run. | |
| 65 | + | #[cfg(not(feature = "count"))] | |
| 66 | + | const ITERATIONS: u32 = 20_000; | |
| 67 | + | ||
| 68 | + | /// Passes, of which the best is reported. | |
| 69 | + | /// | |
| 70 | + | /// Best rather than mean: the distribution's left edge is the machine doing the | |
| 71 | + | /// work with nothing in the way, and everything above it is something else the | |
| 72 | + | /// box was doing. A mean on a dev box measures the browser in the other | |
| 73 | + | /// workspace. | |
| 74 | + | #[cfg(not(feature = "count"))] | |
| 75 | + | const PASSES: u32 = 5; | |
| 76 | + | ||
| 77 | + | /// Iterations before the clock starts, to warm caches and settle the allocator. | |
| 78 | + | #[cfg(not(feature = "count"))] | |
| 79 | + | const WARMUP: u32 = 2_000; | |
| 80 | + | ||
| 81 | + | /// The terminal's viewport. | |
| 82 | + | /// | |
| 83 | + | /// Wide enough that the narrowing pass keeps every column, so the terminal is | |
| 84 | + | /// drawing the same table the webview is, and a real screenful tall rather than | |
| 85 | + | /// tall enough to hold every row. | |
| 86 | + | /// | |
| 87 | + | /// The height is the load-bearing choice and it is what makes the terminal | |
| 88 | + | /// column mean something different from the webview's. A webview emits every | |
| 89 | + | /// row into a string the browser scrolls; a terminal draws one frame and the | |
| 90 | + | /// rest is scroll state. Sizing the viewport to the content would measure a | |
| 91 | + | /// screen no terminal ever draws, and the `Buffer::empty`/`reset` cost of a | |
| 92 | + | /// 400-row viewport swamped everything else when this bench first ran. | |
| 93 | + | /// | |
| 94 | + | /// So read the terminal column as cost per frame, and expect it to be nearly | |
| 95 | + | /// flat across the row counts. Where it is not flat is the interesting part: | |
| 96 | + | /// that is the description being walked before the drawing is clipped. | |
| 97 | + | const TERMINAL: Rect = Rect { | |
| 98 | + | x: 0, | |
| 99 | + | y: 0, | |
| 100 | + | width: 160, | |
| 101 | + | height: 50, | |
| 102 | + | }; | |
| 103 | + | ||
| 104 | + | fn main() { | |
| 105 | + | let tui = terminal_renderer(); | |
| 106 | + | let webview = Webview::new(); | |
| 107 | + | ||
| 108 | + | #[cfg(feature = "count")] | |
| 109 | + | let _session = counting::Session::start(); | |
| 110 | + | ||
| 111 | + | #[cfg(feature = "count")] | |
| 112 | + | println!("allocations per render (allocs / bytes)\n"); | |
| 113 | + | #[cfg(not(feature = "count"))] | |
| 114 | + | println!("nanoseconds per render, best of {PASSES} passes of {ITERATIONS}\n"); | |
| 115 | + | ||
| 116 | + | println!( | |
| 117 | + | "{:>5} {:>22} {:>22} {:>22}", | |
| 118 | + | "rows", "webview fragment", "webview screen", "terminal" | |
| 119 | + | ); | |
| 120 | + | ||
| 121 | + | for size in fixture::SIZES { | |
| 122 | + | let rows = fixture::Rows::new(size); | |
| 123 | + | ||
| 124 | + | let fragment = measure(|| { | |
| 125 | + | let node = fixture::pane(&rows); | |
| 126 | + | black_box(webview.fragment(black_box(&node))); | |
| 127 | + | }); | |
| 128 | + | let screen = measure(|| { | |
| 129 | + | let node = fixture::pane(&rows); | |
| 130 | + | black_box(webview.screen(black_box(&as_screen(node)))); | |
| 131 | + | }); | |
| 132 | + | // The buffer is hoisted and reset rather than rebuilt. A terminal host | |
| 133 | + | // owns one across frames, and `Buffer::empty` for this viewport is | |
| 134 | + | // 64,000 cells: leaving it in the loop measured the allocation and | |
| 135 | + | // buried the drawing under it, which is what the first run of this | |
| 136 | + | // bench actually reported. | |
| 137 | + | let mut buf = Buffer::empty(TERMINAL); | |
| 138 | + | let terminal = measure(|| { | |
| 139 | + | let node = fixture::pane(&rows); | |
| 140 | + | buf.reset(); | |
| 141 | + | tui.node(black_box(&node), &View::new(), TERMINAL, &mut buf); | |
| 142 | + | black_box(&buf); | |
| 143 | + | }); | |
| 144 | + | ||
| 145 | + | println!("{size:>5} {fragment:>22} {screen:>22} {terminal:>22}"); | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | #[cfg(not(feature = "count"))] | |
| 149 | + | println!( | |
| 150 | + | "\nAllocation counts are the stable half and are not in this run.\n\ | |
| 151 | + | Take them with: cargo run --release -p quasi-bench --features count" | |
| 152 | + | ); | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | /// A renderer in a shipped theme, at full colour. | |
| 156 | + | /// | |
| 157 | + | /// Through a bundled theme file rather than a literal because | |
| 158 | + | /// `makeover_tui::Theme` is `#[non_exhaustive]` and `Theme::from_theme` is the | |
| 159 | + | /// only way to get one. The same construction quasi-tui's own tests use. | |
| 160 | + | fn terminal_renderer() -> Tui { | |
| 161 | + | let dir = makeover::bundled_themes_dir().expect("makeover ships themes"); | |
| 162 | + | let colours = makeover::load_theme(&[(dir, false)], "goingson").expect("a bundled theme loads"); | |
| 163 | + | Tui::new( | |
| 164 | + | Theme::from_theme(&colours).expect("a shipped theme resolves"), | |
| 165 | + | Fidelity::TrueColor, | |
| 166 | + | ) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | /// The pane as a whole document, for the screen measurement. | |
| 170 | + | /// | |
| 171 | + | /// A screen rather than a fragment is what a navigation answers, and it carries | |
| 172 | + | /// the shell: head, title, the stylesheet links. Measuring only fragments would | |
| 173 | + | /// report a per-request cost the app does not actually have on first load. | |
| 174 | + | fn as_screen(pane: Node) -> Screen { | |
| 175 | + | Screen::new("Contacts", layout::Arrangement::list_detail(true)) | |
| 176 | + | .with(Slot::new("main", RegionKind::Pane).with(pane)) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | /// One cell of the table. | |
| 180 | + | /// | |
| 181 | + | /// Under `count` this is an allocation count for a single render; under a | |
| 182 | + | /// timing build it is nanoseconds. Two shapes, one call site, because the whole | |
| 183 | + | /// point is that the two runs report the same grid. | |
| 184 | + | #[cfg(not(feature = "count"))] | |
| 185 | + | fn measure(mut render: impl FnMut()) -> String { | |
| 186 | + | for _ in 0..WARMUP { | |
| 187 | + | render(); | |
| 188 | + | } | |
| 189 | + | let mut best = u128::MAX; | |
| 190 | + | for _ in 0..PASSES { | |
| 191 | + | let start = Instant::now(); | |
| 192 | + | for _ in 0..ITERATIONS { | |
| 193 | + | render(); | |
| 194 | + | } | |
| 195 | + | best = best.min(start.elapsed().as_nanos() / u128::from(ITERATIONS)); | |
| 196 | + | } | |
| 197 | + | format!("{best}") | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | /// One cell of the table, counting rather than timing. | |
| 201 | + | /// | |
| 202 | + | /// One render, not a loop. An allocation count is exact, so averaging it over | |
| 203 | + | /// 20,000 iterations would divide a number by itself and add rounding. | |
| 204 | + | #[cfg(feature = "count")] | |
| 205 | + | fn measure(mut render: impl FnMut()) -> String { | |
| 206 | + | // One untimed render first: the first call through a renderer populates | |
| 207 | + | // whatever it lazily builds, and counting that would charge the screen for | |
| 208 | + | // a cost the second request does not pay. | |
| 209 | + | render(); | |
| 210 | + | ||
| 211 | + | let (blocks_before, bytes_before) = counting::read(); | |
| 212 | + | render(); | |
| 213 | + | let (blocks_after, bytes_after) = counting::read(); | |
| 214 | + | ||
| 215 | + | format!( | |
| 216 | + | "{} / {}", | |
| 217 | + | blocks_after - blocks_before, | |
| 218 | + | bytes_after - bytes_before | |
| 219 | + | ) | |
| 220 | + | } |