Skip to main content

max / quasi

Add quasi-bench: 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 and nothing noticed. The 42x figure in the 2026-08-14 conversion cost report came from a throwaway crate in a scratchpad, so it cannot be re-read. Renders a copy of MNW's library_contacts pane at 5, 25 and 200 rows through the webview (as a fragment and as a whole screen) and the terminal, reporting nanoseconds in a default build and allocation counts under --features count. Two builds because a counting allocator is instrumentation and a timing number taken through it measures the instrumentation. quasi-immediate is absent: its entry point needs a live egui Ui, so measuring it means an egui harness rather than a function call. dhat rather than a hand-written GlobalAlloc because the workspace forbids unsafe_code and forbid is not overridable per crate.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-15 18:08 UTC
Signed with PGP, not checked
Commit: 45e114eb9f37127463bec7faba076a0dcfb0c013
Parent: 51972ee
7 files changed, +651 insertions, -9 deletions
M Cargo.lock +109 -9
@@ -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"
M Cargo.toml +1
@@ -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 + }