Benchmark record

Captured numbers from the criterion suite, indexed against the performance commitments in ../architecture/design.md §8.2 (Floor / Target / Today / Stretch).

This document is a snapshot, not a moving record -- update it when a deliberate perf change lands or a new bench is added; do not bump numbers on every routine run. Commit history is the moving record.

Each row points back at the §8.2 commitment it backs, so a regression here is identifiable as a violation of a specific target rather than just a slower number.

On the Floor / Target column. "Floor" is the typical achieved number on this hardware (median of recent runs). "Target" is what we want to keep delivering, not the §8.2 spec ceiling. Where current achievement is well under the §8.2 ceiling we set the target tight (~2-3× the floor) so any meaningful regression flags. Strive for best: the spec value is a guide, the achieved number is the bar. When a regression is intentional the row's target moves with the new floor in the same commit -- never silently relaxed.

Run cargo bench --workspace to reproduce. Times shown are criterion's median estimate. Each row's outer [low high] bracket is the 95% confidence interval; we report the median.

⚠️ Hardware caveat — read before quoting these numbers. Every row in this document was measured on the primary development box: AMD Ryzen 7 9700X (Zen 5, 8C/16T, ~5 GHz boost), 16 GB RAM, WSL2 / Linux 6.6. This is high-end desktop hardware as of 2026 and is NOT representative of the machines most users will run lattice on.

Concrete implications:

  1. The numbers here are a best case. Typical 2020-2024 ultrabook / Macbook Air baselines run 2-5× slower on single-thread CPU work (tree-sitter parse, fuzzy match, annotator pipeline, ropey edits). Older or low-power hardware can be 5-10× slower. A bench showing 1 µs on the dev box could be 5-10 µs in the wild.

  2. Paramount goal #1 applies to user hardware, not the dev box. The §8.2 commitments (keystroke → glyph imperceptible — within one display frame, the physical ceiling: ≤ 8.3 ms at 120 Hz, ≤ 16 ms at 60 Hz — and ratcheted down by CI, never regressing; per-call WASM overhead < 500 ns p99; grammar-extension round-trip < 5 µs p99) are commitments we make to USERS. A bench that fits the budget on the dev box but consumes more than ~25% of it (the rough cross-hardware safety margin) is at risk of overshooting on slower machines. Headroom matters more than the absolute number does.

  3. Regression detection stays valid. Comparing pre-vs-post-slice numbers ON THE SAME HARDWARE is the point of these benches and is unaffected by the dev-box issue. A 2× regression flagged here would also flag on slower hardware. Don't avoid running benches because of this caveat — just don't quote the absolute numbers as "this is what users see."

  4. A cheaper baseline machine is on the wish list. Cross-validating budget-sensitive rows (frame-budget bench, host-call bench, per-keystroke hot paths) on a representative low-end laptop would let us catch the cases where dev-box numbers mislead. Tracked as a follow-up; not blocking individual benches.


PC.9 — listing a directory, per keystroke (2026-09-10)

⚠️ Apple M1 Pro, macOS 14.5, rustc 1.94.0. cargo bench -p lattice-picker --bench picker -- dir_listing.

dir-pick re-lists on every keystroke through on_query_changed, and that hook runs synchronously on the actor thread (dispatch.rs fire_live_picker_query_changed) — so this is inside a keystroke, not beside one. Design: ../architecture/project-commands.md §9 H5.

Subdirectoriesbeforeafter
154.1 µs55.2 µs
50182.0 µs116.7 µs
500015.2 ms7.4 ms

The bench found a stat per entry. path_entries called entry.metadata() on every entry to answer is_dir and to read a size — one syscall each, and the size is only ever shown for files. file_type() answers is_dir from the read_dir buffer for free, and the size is now read only when a file listing will show it. Symlinks still pay a metadata(), because file_type() reports the link rather than its target and a symlinked directory that stopped being listed would be a real regression (~/src -> … is common).

This is shared code: gen:files and gen:directories take the same halving, so <Tab> path completion on the : line got faster without being touched.

5000 is a deliberately pathological top end — 5000 subdirectories in one directory, not 5000 files. Real directories a user browses to sit in the tens, where the cost is ~100 µs and invisible. The row is here to pin the SHAPE: one read_dir of one directory, scaling with that directory alone. The rejected alternative (walk_files_for_picker with directories) would scale with the whole tree, and would show up here as growth that tracks depth.

Bench under no other load. The first run of this was taken beside my own cargo check and reported the 1- and 50-entry cases as 90% and 24% regressions — the opposite of the truth. Same trap as scripts/precommit.sh's concurrency refusal, and worth the same reflex: if a number moves the wrong way, re-run it alone before believing it.


ZP.5 — pane layout, zoomed vs. unzoomed (2026-09-09)

⚠️ Apple M1 Pro, macOS 14.5, rustc 1.94.0. cargo bench -p lattice-core --bench pane_layout.

PaneTree::compute_rects is on the per-frame path in both peers — the TUI draw path calls it, and so does the per-pane viewport-sizing loop. Pane zoom (<C-w>z) adds a branch at its head. Design: ../architecture/pane-zoom.md §8.

Leavescompute_rects unzoomedzoomed
118.3 ns— (zoom is a no-op on one pane)
222.1 ns19.7 ns
431.5 ns19.7 ns
864.7 ns21.7 ns

Zoom is cheaper than not zooming, and flat in pane count. That is the claim §8 makes and the reason it is worth measuring: the zoomed path returns one rect instead of walking N leaves, so it replaces work rather than adding a check on top of it. The unzoomed column roughly doubles from 4 to 8 panes; the zoomed column does not move.

The real saving is downstream and not measured here — a leaf with no rect gets no element fan-out and no per-pane content resolution, which is a much larger number than the walk itself.

render_root (the GPUI peer's entry)2 leaves8 leaves
unzoomed1.82 ns1.81 ns
zoomed2.56 ns4.95 ns

The zoomed arm here is the slower one, and it scales with leaf count — recorded rather than smoothed over. Zoom is keyed on PaneId (it must be: close_active renumbers leaf indices), so render_root resolves the id through index_of, a linear scan of the leaves vec. At 5 ns for 8 panes this is far below noticing, and the alternative — caching an index alongside the id — would reintroduce exactly the staleness the id exists to prevent. Worth revisiting only if pane counts ever reach the dozens, which the split grammar does not really invite.


LG.3b — what a wasm grammar costs outside the parse (2026-08-23)

⚠️ Apple M1 Pro, macOS 14.5, rustc 1.94.0.

LG.1 measured parsing. This measures the part that is not the parse: a wasm-backed Language can only be used by a Parser that owns a WasmStore, and a parser without one fails set_language outright. These three numbers decide where stores come from (plugin-languages.md §2.5). crates/lattice-syntax/benches/wasm_vs_native_parse.rs, group wasm_store.

OperationCost
WasmStore::new5.08 ms — compiles tree-sitter's wasm libc
load_language101.6 ms — Cranelift compiling the grammar
bind an already-loaded Language into another store67.9 µs

The 1500× gap between the last two is the whole design. Loading is not cached by the Engine — the same bytes into a second store pay the full 101 ms again — but a Language is portable into any store, and outlives the store it was loaded from. So a grammar is compiled exactly once, at plugin load on an off-thread task, and the Language is what gets kept.

Where stores come from, and why it is two answers. Syntax's parser is long-lived and needs its store for every later reparse, so it gets its own: 5 ms once per buffer whose language is wasm-backed, off the keystroke path. Injection highlighting builds a fresh Parser per injection, per highlight call, so it borrows a thread-local store and returns it — a markdown file with twenty fenced blocks would otherwise pay 20 × 5 ms on every highlight. That is sound because a Tree survives its parser's store being taken back.

Native grammars pay none of this: both entry points check Language::is_wasm first.

Method note: the bind figure was 5.57 ms on the first run because store creation sat inside the timed routine. Moved to iter_batched setup, it is 67.9 µs — the earlier number was measuring the thing it was supposed to be compared against.


OR.6 — a find-node picker open (2026-08-30)

⚠️ Apple M1 Pro, macOS 14.5, rustc 1.94.0. cargo bench -p lattice-plugin-host --bench roam_find_open.

What :org-roam-find-node costs against the reference corpus's 585 nodes. Design: ../architecture/org-roam.md §5.

WorkloadTime
store_get_nodes — the one get of the whole blob1.58 µs
deserialize_nodes — 585 records out of 77.5 KB130 µs
rank_first_frame_585 — fuzzy match + rank, per keystroke295 µs

The blob is 77,525 bytes for 585 nodes, against the ~90 KB §4.2 predicted.

295 µs is the only one with a budget, and it is the measurement that settles the design question. Matching stays native precisely so it does not cost a WASM crossing per keystroke; had it been done in the guest, this number would be 295 µs plus a boundary round trip on every character typed. It is ~3.5% of a 120 Hz frame, so the picker keeps up with a fast typist on a corpus this size — and it scales with node count, which is the thing to watch if someone points roam at ten thousand notes.

The other two happen once per open, where 132 µs is invisible. They are measured separately from the rank for exactly that reason: a regression in ranking must not be able to hide behind an open-time cost nobody feels.

One get, not 585. 1.58 µs to read the whole blob is what justifies §4.2 keeping nodes beside the per-id records at all — the alternative is one host call per node, on the open path.


OR.1 — the plugin byte store (2026-08-30)

⚠️ Apple M1 Pro, macOS 14.5, rustc 1.94.0. cargo bench -p lattice-plugin-host --bench plugin_store.

The durable, plugin-scoped key/value store org-roam's index lives in. Design: ../architecture/plugin-host.md §5, ../architecture/org-roam.md §4.2. These numbers bound OR.4 (the indexer) and OR.6 (find-node), so they were taken before either was written.

WorkloadTime
get_record_200b_of_585 — one n/<id>, keystroke path76.6 ns
get_blob_90k — the whole nodes blob, one picker open1.62 µs
keys_prefix_of_1000 — a prefix scan, 500 of 1000 match10.8 µs
put_record_200b18.7 µs
put_blob_90k17.3 µs

76.6 ns is the number that mattered. <CR> on an [[id:…]] link is one exact-key get plus one generation compare, and the whole grammar-extension round trip is budgeted at 5 µs p99 — so the store is ~1.5% of it. That is what justifies §4.2 keeping n/<id> as a separate key from nodes: deserializing a 90 KB blob to answer one question is not a thing to do while someone holds a key down, and now there is a measurement rather than an assertion behind that sentence.

The two put rows being the same is the finding, not a coincidence. A 90 KB value and a 200-byte value cost within 8% of each other, because neither is measuring the insert — both are measuring 1/64th of a file write. The store flushes every 64 mutations (agenda_cache.rs's policy, promoted to a primitive), so a put is a BTreeMap insert plus an amortized temp-file-and-rename. Two consequences worth naming: the cold index's ~2000 writes cost ~37 ms of flushing, off-thread, which is nothing against the parse they accompany; and tuning FLUSH_EVERY is the lever if that ever matters, not the encoding.

get clones. 1.62 µs for 90 KB is a memcpy at ~55 GB/s, which is what crossing into a guest costs anyway — the bytes have to be copied into linear memory regardless, so returning a borrow would move the copy rather than remove it.


CD.5 — capture drafts in the store (2026-09-17)

⚠️ Apple M1 Pro, macOS 14.5, rustc 1.94.0. cargo bench -p lattice-plugin-host --bench plugin_store -- capture_drafts.

The drafts picker lists keys("capture/") on every open, and a commit whose capture is another's caller will scan the same prefix (CD.8). The store here also holds a 585-record roam index, which is the realistic neighbour. Design: ../architecture/org-capture-drafts.md §13.

Live draftskeys("capture/")
173.7 ns
10372 ns
1002.47 µs

Linear in the drafts, not in the store. The 585 roam records beside them cost nothing visible: 1 draft is 74 ns against a 1000-entry keys_prefix scan's 10.7 µs, because the store is a BTreeMap and a prefix range skips what does not match. 100 drafts — more than anyone leaves unfiled — is 2.5 µs on a user-initiated picker open, far below anything perceptible. Nothing about drafts is on the typing path.

H.3 — conceal matching, per display line (2026-08-29)

⚠️ Apple M1 Pro, macOS 14.5, rustc 1.94.0. cargo bench -p lattice-syntax --bench conceal.

conceal_spans is the one new per-line cost concealment adds to a display-matrix rebuild. Design: ../architecture/conceal.md.

WorkloadPer line
no_rules — every language but org3.25 ns
org's two rules, prose (no match)99.4 ns
org's two rules, three links on the line1.54 µs

The zero-cost claim is now a measurement. conceal.md says a buffer whose language declares no rules "pays nothing"; 3.25 ns is the is_empty() branch and the empty Vec, and it is what keeps this feature off every Rust, Python and Markdown buffer in the editor.

Read the org numbers per rebuild, not per frame. A ~50-line viewport of ordinary org prose costs ~5 µs; a pathological viewport where every line carries three links costs ~77 µs. Both are off the UI thread, on a matrix rebuild rather than a paint, and a real org file is overwhelmingly the first case — links cluster in a few lines rather than spreading over all of them.

1.54 µs for three links is higher than a linear-time engine suggests, and the cause is captures_iter's per-match allocation rather than the matching. It is recorded rather than optimised because the number that would justify the work is the per-rebuild one, and 77 µs off-thread is not where a rebuild's time goes. If a future workload makes it matter, captures_read with a reused Locations is the fix and needs no design change.


LG.3a — the live language registry (2026-08-23)

⚠️ Apple M1 Pro, macOS 14.5, rustc 1.94.0.

LangRegistry::standard() stopped being a OnceLock read and became an ArcSwap snapshot of the live registry, so a plugin language lands in the same map bundled ones live in — no second lookup and no kind-branch in any accessor (plugin-languages.md §2.4). Its production callers are per-buffer (Syntax::for_language) and per-hunk (magit's diff highlighting), so the swap needed measuring rather than assuming.

MeasureValue
LangRegistry::standard() snapshot13.9 ns

Not free, and not worth avoiding. An ArcSwap::load_full is a little more than the Arc::clone it replaced — both are one atomic RMW, but the swap-capable one does more work. Across a 2000-hunk diff that is ~28 µs, against an 8.3 ms frame. The bundled set is still compiled exactly once (~1.2 s); registration clones the map, not the queries, which is what Arc<LangConfig> is there for.


LG.2 — Lang::detect_from_path with a runtime registry (2026-08-23)

⚠️ Apple M1 Pro, macOS 14.5, rustc 1.94.0.

Detection used to be a pure match over an extension string. LG.2 adds a fallthrough consulting the runtime language registry (plugin-languages.md §2.3), and the function has nineteen call sites — magit's diff highlighting calls it per hunk, grep highlighting per result. crates/lattice-syntax/benches/lang_detect.rs.

CaseEmpty registryPopulated
main.rs (native arm)66.0 ns73.2 ns
unmatched extension89.4 ns136 ns
plugin extension—144 ns

The empty case is free, by construction rather than by luck. A relaxed AtomicBool short-circuits before the ArcSwap is touched, so a session with no language plugins pays nothing — 89.4 ns unmatched is the same work the old code did. The native rows are equal within noise because a native arm returns before the registry is reached at all; the 7 ns spread is measurement order, not a real cost.

With a language registered, an unmatched extension pays ~47 ns for the ArcSwap load plus a hash lookup. At 2000 hunks that is ~0.3 ms of one-off work, against an 8.3 ms frame — not close to mattering.

Incidental finding, not fixed here. The ~66 ns floor is mostly the String allocation from to_ascii_lowercase() in detect_from_path, which predates LG.2 and is paid on every call including the native ones. If the per-hunk call sites ever matter, that allocation is the thing to remove, not the registry lookup.


LG.1 — wasm grammar vs native parse (the §4 gate, 2026-08-23)

⚠️ Apple M1 Pro, macOS 14.5, rustc 1.94.0. Not comparable to the Ryzen rows further down.

Plugin-contributed languages ship their grammar as WebAssembly (plugin-languages.md §2), and the ecosystem quotes 2–5× for wasm-vs-native tree-sitter parsing. LG.1 was a gate: measure it here rather than inherit it, and re-open the design's §6 fallback if the real ratio was materially worse than 5×.

Both sides run the same grammar — tree-sitter-md, once via tree_sitter_md::LANGUAGE and once via WasmStore::load_language on the same parser.c + scanner.c built to wasm. So the ratio isolates the loading mechanism, not the language. crates/lattice-syntax/benches/wasm_vs_native_parse.rs, run with --features wasm-grammar.

CorpusCold nativeCold wasmRatioIncr. nativeIncr. wasmRatio
16 §§ (3.5 KB)849 µs1.73 ms2.04×487 µs605 µs1.24×
128 §§ (29 KB)6.78 ms14.24 ms2.10×3.83 ms4.81 ms1.26×
512 §§ (118 KB)27.67 ms55.29 ms2.00×16.06 ms19.91 ms1.24×

Gate: passed, at the good end of the expected band. Cold parse is a flat 2.0× across two orders of magnitude of input — the ratio does not degrade with size, which is the property that mattered. Incremental reparse is only 1.25×.

Why incremental is so much cheaper than cold, and why that is the number to care about. An incremental reparse reuses subtrees, and the reuse is host-side C manipulating the tree — identical work on both sides. Only the newly-lexed region runs guest code. So the more a reparse reuses, the closer wasm gets to native, and the reparse is the path a user waits behind repeatedly. The 2× cold figure is paid once, on open, where the UX contract already permits "highlighting catches up".

What this does not say. These are parse numbers, not keystroke numbers — parsing is off the keystroke path (async reparse; the renderer never blocks on it), so nothing here touches the keystroke→glyph ratchet. And the ratio is measured on one grammar; a grammar with a heavier external scanner would shift the mix between guest and host code, in either direction.

Toolchain note, because it changes who can reproduce this. The wasm artefact is built by scripts/build-wasm-grammar.sh using clang + rustup only — no emscripten, no docker, no tree-sitter CLI. tree-sitter's wasm store supplies its own wasm libc and the memory/table/stack imports, so a grammar needs only to be a plain wasm-ld -shared side module against ~60 lines of header declarations. See the script's header for the two non-obvious link flags. crates/lattice-syntax/tests/wasm_grammar.rs builds through that script and asserts the wasm and native trees are identical, cold and after an incremental edit.


HD.5 — compressed embedded help docs (2026-07-29)

⚠️ Measured on the Apple Silicon box, like the MG.14 rows below and unlike everything further down. Not comparable to the Ryzen numbers.

User docs are embedded deflate-compressed and inflated on first open into a OnceLock cache (495 KB raw → 197 KB embedded, 2.5×). The laziness is the part worth guarding: boot must not decompress anything, or every session pays for docs most users never open.

Bench file: crates/lattice-help/benches/topics.rs. Run: cargo bench -p lattice-help --bench topics.

BenchMedianFloor / TargetWhat it measures
help_registry_boot_ns~17.8 µs (73 topics) → ~39.4 µs (137 topics)18 µs / 50 µsbuiltin_topics() on the editor-boot path, for every session. Decompresses nothing — one name/summary String pair per topic and the map build. If this starts scaling with doc volume, laziness has regressed. See the 2026-08-22 note below: the growth is in topic COUNT, which is expected, and headroom is now thin.
help_topic_first_open_ns~66.6 µs67 µs / 250 µsInflate + cache fill for the largest topic (modal-editing, ~26 KB). The one-time cost per topic per session, on an explicit :help.
help_topic_cached_open_ns~562 ns → ~597 ns570 ns / 2 µsEvery later open of the same topic: a clone of the cached string.
help_registry_handle_read_ns (CR.1)~608 ns— / 2 µsThe same cached open, taken through the ArcSwap handle the registry now lives behind: .load() + lookup + render. The delta against the row above IS the RCU wrapper — ~10 ns, inside run-to-run noise. Making the registry runtime-writable so plugins can ship :help pages costs the existing read path nothing.

Boot-time growth, measured 2026-08-22. The 17.8 µs baseline was recorded against 73 topics; docs/user/ now holds 137. That is 0.244 µs/topic then and 0.287 µs/topic now — linear in topic count, which is exactly what this bench's note says to expect, since it builds one name/summary entry per topic. It is not a laziness regression: the boot path still decompresses nothing, and first_open is unchanged in shape.

It is worth stating plainly anyway, because the headroom is thinner than the table suggests: at ~0.29 µs/topic the 50 µs target is reached around 170 topics, and the doc set nearly doubled in under a month. The lever when it fires is not a faster map build — it is deferring the name/summary Strings themselves, which the current shape materialises eagerly for every topic whether or not :help is ever opened.

All of these sit on an explicit user action, never per-keystroke or per-frame, so the bar is "imperceptible within a command" rather than the frame budget. Context in embedded-docs-budget.md.


MG.14 — magit headerline (2026-07-28)

⚠️ Measured on a different box than every other row in this file: Apple Silicon / macOS, not the Ryzen 9700X WSL2 dev box the hardware caveat above describes. Treat these as same-order-of-magnitude, not comparable to the rows below.

The magit headerline resolves its theme colours inside render() and folds the theme's resolved version into its own version(), so :colorscheme repaints the row — unlike the compilation and ai-conversation headerlines, which capture u32s at activation and go stale. That choice puts a theme-registry read-lock on the cells worker's every tick, which is exactly what magit_headerline_version_ns exists to keep honest.

Bench file: crates/lattice-magit/benches/headerline.rs. Run: cargo bench -p lattice-magit --bench headerline.

BenchMedianFloor / TargetWhat it measures
magit_headerline_version_ns~26 ns26 ns / 100 nsversion() — called every tick per visible magit buffer. Theme read-lock + ArcSwap load + one atomic. The row that must stay flat; a regression here means the theme-live choice stopped paying for itself.
magit_headerline_render_ns~581 ns580 ns / 2 µsrender() — builds the widest row any view produces (status: repo + branch + ahead/behind + three counts) as cells. Called only when the version advanced, i.e. on real change.
magit_headerline_set_unchanged_ns~129 ns130 ns / 500 nsThe no-work refresh path: set() with identical fields. Every gr and every future auto-refresh that finds the repo unchanged lands here, and must not bump the version (paramount goal #1 — no repaint for no change).

Backs design §8.2's "UI thread does no work proportional to content" posture indirectly: all three run on the cells worker, never the UI thread, so the bar is "cheap enough to do every tick" rather than the frame budget itself.


MG.18c — magit hunk resolution (2026-07-29)

⚠️ Same box caveat as MG.14 above: Apple Silicon / macOS, not the Ryzen 9700X WSL2 dev box.

s / u / x resolve the hunk under the cursor from the buffer's own text. The parser reads lines through an accessor and stops at the @@ header's declared counts, so the work is proportional to the hunk, not to the buffer holding it. These three rows exist to keep that honest: the obvious simplification — collect the buffer into a Vec<String> and slice it — is shorter, passes every correctness test, and turns one keypress in a large *magit:diff* into an O(document) copy on the actor thread.

Bench file: crates/lattice-magit/benches/hunk_staging.rs. Run: cargo bench -p lattice-magit --bench hunk_staging.

BenchMedianFloor / TargetWhat it measures
magit_hunk_at_small_200_lines~646 ns640 ns / 5 µsCursor inside the first hunk of a 200-line diff: parse + build the standalone patch. The baseline the two rows below are compared against.
magit_hunk_at_large_50000_lines~634 ns630 ns / 5 µsThe same cursor position in a 50,000-line diff. Must stay within noise of the row above — a number that tracks buffer size means the accessor was replaced by a collect.
magit_hunk_at_last_hunk_of_50000_lines~620 ns620 ns / 5 µsCursor in the last hunk of that buffer, where the backward scan for the file header is furthest from row 0. That distance is bounded by one file's diff, not the document.

Runs on the actor thread (an action handler), not the UI thread, so the bar is "imperceptible per chord press" rather than the frame budget — but a regression here is felt directly, as the editor going quiet after s.


PH7.7a — grammar-extension boundary marshalling (2026-07-12)

The host-side marshalling half of the grammar-extension round-trip < 5 µs p99 commitment (design §8.2 / plugin-host.md §4.1). PH7.7a lands only the boundary type mirrors + boundary_grammar.rs conversions; the end-to-end guest↔host trampoline (wasmtime canonical-ABI lift/lower + the sync guest call) lands and is gated at PH7.7c/d, where an actual guest call exists to measure. These numbers isolate the pure conversion cost so the 5 µs budget's marshalling share is visible from day one.

Bench file: crates/lattice-plugin-host/benches/grammar.rs. Run: cargo bench -p lattice-plugin-host --bench grammar.

BenchMedianWhat it measures
grammar_project_motion_context~41 nsHost→guest projection of a MotionContext (buffer-id/from/count/args — an Args::List of 2; the string clones dominate). The per-dispatch hot path a plugin motion's trampoline runs.
grammar_project_operator_context~42 nsProjection of an OperatorContext (range/register/count/Args::List).
grammar_effect_from_wit~31 nsGuest→host Effect::from_wit (the operator/ex-command result the trampoline maps back).
grammar_project_text_object_context~11 nsProjection of a TextObjectContext (Args::None — no string clone).
grammar_motion_result_round_trip~314 psMotionResult to_wit → from_wit (two Position u32s + a bool).

Read: every conversion is tens of nanoseconds — well under 1% of the 5 µs round-trip budget, so the trampoline's wasmtime call (PH7.7c/d) owns effectively the whole budget. Bulk buffer text never rides a context (it crosses via the document resource handle, §4.2), so projection cost is O(context scalars), not O(buffer). Not a ratcheted CI row yet — that is the PH7.7d gate.


PH7.7d — grammar-extension round-trip (the §7 gate, 2026-07-12)

The end-to-end cost a plugin motion pays on every dispatch (the PH7.7 sync fork): execute_motion_only → project the MotionContext → the synchronous guest apply-motion call (canonical-ABI lift/lower, no runtime) → MotionResult:: from_wit. Measured through the grammar-guest fixture's down-n motion, registered into a real CommandRegistry and dispatched exactly as a builtin. This is the §8.2 / §7 grammar-extension round-trip < 5 µs p99 commitment, now that the seam exists.

Bench: crates/lattice-plugin-host/benches/grammar_roundtrip.rs (grammar_motion_round_trip). CI gate: tests/perf_ratchet.rs (grammar_round_trip_stays_within_ceiling).

MeasureValueNotes
Release median (bench)~340 ns~15× under the 5 µs p99 budget — the whole sync round-trip through a real wasm guest.
Debug median (ratchet)~2.3 µsUnder cargo test (debug); the CI gate ceiling is a generous 250 µs (≥100× headroom, catches a gross regression without tripping on runner variance).

Read: the sync trampoline is fast — the marshalling (PH7.7a, tens of ns) + the wasmtime typed call together land at ~340 ns release, comfortably inside the budget even before the motion does any work. The Reflex budget that bounds a runaway plugin motion is fuel-primary (PluginBudget::grammar: 10M fuel ≈ one frame of compute; the epoch is a 50 ms jitter-proof backstop, not a tripwire — a 2 ms epoch false-positived on OS descheduling under the bench's warmup, the bug this bench caught). Built-in motions stay native and pay none of this.


TS.1 — the sync tree-sitter query (enclosing, 2026-07-20)

The ONE tree-sitter-seam operation on the synchronous grammar path (plugin-treesitter-seam.md §6): auto-pair's manual close key / backspace (AP.3) calls tree-snapshot.enclosing(cursor, kinds) from a grammar action, on the dispatch thread, inside the grammar Reflex budget. The design claim is that it does no parsing (the tree is already there) and is a single bounded walk — descend to the cursor's smallest node, then up the ancestor chain. This bench isolates the native host-side walk the trampoline runs before any result crosses (the WASM round-trip is bounded separately by grammar_roundtrip).

Bench: crates/lattice-plugin-host/benches/tree_enclosing.rs. Corpus: a 2000 top-level-function Rust file (~4k lines), queried from the file midpoint.

MeasureValueNotes
enclosing (block, midpoint)~1.12 µsDescend + ancestor walk, no parse. ≫ inside the ~2 ms Reflex budget.
node_at (midpoint)~763 nsThe descent alone.

Read + the bug this bench caught: the first cut descended with a linear sibling scan (0..child_count, checking each child's span), which at the root of a 2000-item file is O(n) — it measured 175 µs, ~150× slower and a real paramount-#1 violation on the sync path. The fix routes the descent through a TreeCursor::goto_first_child_for_point (tree-sitter's internal binary search over the child list), collapsing each level to O(log fanout) → the 1.12 µs above. The prose claim ("bounded walk, no parse") is only true with the cursor; the bench is what made the O(n) cut visible instead of shipping it.


TB.4 — what a table realign costs (2026-09-03)

⚠️ Apple M1 Pro, macOS 14.5, rustc 1.94.0. Same box as the LG / MG / H.3 rows and the "Full-suite baseline — Apple M1 Pro" section below, so those are the comparable neighbours; not comparable to the Ryzen 9700X / WSL2 rows.

table-mode realigns a whole table on every field exit — <Tab>, <S-Tab>, <CR>, <Esc>. The question TB.4 opened with was whether it could instead run on every keystroke, as the slice's premise (wrongly) claimed emacs does.

Bench: crates/lattice-mode/benches/table.rs, 5 columns, swept across rows.

rowsparseparse + render (one realign)
53.2 µs7.5 µs
5031.9 µs77.9 µs
500339.6 µs828.9 µs

Linear in cells, as expected — 10× the rows is ~10× the time in both columns.

Two recognition numbers matter more than the realign itself, because they run far more often:

a line that is not a table (Table::at miss)22.2 ns
a table 2000 lines into a file82.8 µs

The miss is the path every declining <Tab> in an ordinary paragraph takes — one starts_with and out. The second is the #+BEGIN_/fence scan TB.1 added so a | a | b | inside a source block is not treated as a table; it counts delimiters from the top of the file, so a table deep in a document pays for everything above it. Per chord, not per frame, and 83 µs is comfortable there — but it is the number to look at first if a table chord ever feels slow in a long file.

What this decided: nothing. A realistic table realigns in 7.5 µs, under 0.1% of a 120 Hz frame — and this box is the slower of the two on pure single-thread compute (the full-suite baseline below puts the M1 Pro 1.5-2× behind the Zen 5 on tight scalar loops), so the headroom is real rather than flattered by the hardware. Cost was never going to be the objection. The objection is the keystroke UX contract — a realign rewrites every row, and per keystroke that is a pixel change to content the user did not edit. The bench exists so that is on the record as a contract decision rather than a performance one, and so the field-exit realign that does run interactively has a baseline. See ../architecture/table-mode.md §8.


OA.0a — the tree WALK, and the same bug a second time (2026-09-01)

tree_enclosing above pins the ancestor walk: one path, cursor to root. A guest scanning a file for structure does the other shape — visit every child of a node, read each one's kind and range, resolve a field on some. The org agenda's walk_sections is exactly that, and it was quadratic.

NodeResource is a path of child indices from the root, re-resolved on every accessor. The final step is Node::child(i), and tree-sitter walks the sibling list to reach i — so resolving the i-th child is O(i), not O(1), and the module header's claim that "resolution is O(depth)" was wrong in the one place it mattered. named_child compounded it by rescanning the child list from zero on each call. A pass over one node's k children was therefore O(k²).

Bench: crates/lattice-plugin-host/benches/tree_walk.rs, swept across fan-out because a single size cannot tell linear from quadratic.

childrenbeforeafter
100—83.0 µs
200—170.0 µs2.05×
400—345.1 µs2.03×

Ratios are what to read: 2× per doubling is linear, 4× would be the bug back.

The end-to-end effect, one guest scan of an org file (debug build, via the real plugin):

filebeforeafter
2.1 KB183 ms102 ms1.8×
8.5 KB1.65 s131 ms12.6×
17 KB6.67 s180 ms37×
34 KB28.9 s280 ms103×

The fix memoises two things on the resource — the node's own kind/range/flags, and one TreeCursor pass over its children indexed both ways so named_child is O(1). A snapshot is immutable, so a cached answer cannot go stale. No WIT change and no guest change: the seam's API is identical.

This is the second time this exact class has landed here. TS.1 above shipped a linear sibling scan at the root of a large file and was fixed with a cursor; this is the same "walk the siblings to reach index i" cost in a different accessor, and it survived because the sync path TS.1 benched was never the path a bulk walk takes. The lesson is not "use a cursor" — it is that fan-out is the axis this seam degrades on, and a bench at one size cannot see it. Any new node-API surface gets a swept bench or it is not covered.

What it looked like as a bug report: "agenda on refresh breaks (does not load anything back)". Nothing about it pointed at tree-sitter. The agenda view is cleared before its scan is spawned, so a slow scan reads as empty on refresh and merely as loading on first open — which is why it was reported as a refresh defect, and why the refresh path (which is correct, and has four passing end-to-end tests) was the first place looked.


Predictive indent, after the freshness-gate fix (2026-08-16)

The gate deciding whether <CR> / o / O consult the tree asked reparsed_from_version() == text_version() — a delta baseline, which an incremental reparse can never satisfy. So after the first edit in a session every keystroke took the Lexical branch, permanently. The indents.scm engine IN.2 built and benched was running approximately never.

The bench could not have caught this, and that is the lesson. It boots a fresh editor, where the initial full parse makes even the broken gate pass, so indent_query always measured the tree path. Production almost never had a freshly-parsed buffer. A bench that only measures the healthy path cannot tell you the path is unreachable.

Re-measured after the fix (same Apple-silicon box as IN.2; not comparable to the Ryzen/WSL2 rows elsewhere):

Measure80 lines800 lines3200 lines
indent_query — the per-<CR> cost8.87 µs9.34 µs10.11 µs

Against IN.2's 8.39 / 8.82 / 9.49 µs that is +6%, and criterion flags it as a regression. It is not one: the change swaps one u64 comparison for another u64 comparison, which cannot cost 6%. It is run-to-run variance on a warm machine, and it is recorded rather than quietly re-baselined.

What did change is what production pays. Before the fix, every <CR> after the first edit cost indent_method/keep — 607 ns, the bracket-scan bridge. Now it costs indent_method/syntax — 9.34 µs. Predictive indent got ~15× more expensive per keystroke in exchange for being correct on the languages the bridge cannot serve (Python, YAML: def f(): is bracket-balanced, so the scan finds nothing to indent from). 9 µs against an 8.3 ms frame is 0.1% of the budget, so the trade is not close.

indent_method (100-fn file)ValueNotes
syntax9.34 µsTree path — what a <CR> now actually costs.
keep607 nsLexical copy; what it silently cost before.
none433 nsColumn zero.

IG.6 — indentation guides (2026-08-16, worker side re-measured 2026-08-18)

Guides add work in two places under different constraints, so they are benched separately (docs/dev/architecture/indent-guides.md).

⚠️ Measured on Apple silicon (macOS), NOT the Ryzen/WSL2 box most rows in this document used. Not comparable to the tables below. Bench: crates/lattice-host/benches/indent_guides.rs. Corpus: generated Rust, three levels deep, blank lines inside every block so the walk cannot stop at the first one.

The worker side — build_indent_guides, run in the pass that builds each pane's DisplayMatrix, i.e. on every rebuild. The claim it has to keep is that it is bounded by the covered window, not by the file:

guide_build (250-row window)600 lines2400 lines9600 lines
build + per-row resolution23.5 µs23.4 µs23.4 µs

Flat to within noise across a 16× file-size range. That is the property the bench exists for: a version that walked the whole rope would look fine on the 600-line row and fall over on the last one.

But the covered window is not always 250 rows — below cells_worker::WINDOW_CAP_LINES (2048) the display matrix covers the whole document, so on a normal source file the table above measures a case the keystroke path does not take. Two defects lived in that gap, both fixed 2026-08-18:

guide_build_whole_doc767 lines2039 lines3071 lines
per line93 ns92 ns94 ns
build71.4 µs188 µs289 µs
⟵ before the row sweep158 µs746 µs1.52 ms
⟵ before both (per-line descent)255 µs982 µs1.86 ms
  1. The read pattern. The layer fetched its covered range one line at a time — one O(log n) rope descent per line. It now reads that range from a single Lines cursor (Buffer::line_shapes_from). The bottom row is the old pattern, kept as a permanent control because the two are indistinguishable in the output: only the clock tells them apart, so a refactor that reintroduced per-line access would pass every guide test.
  2. The per-row resolution was O(covered lines × blocks) — every covered line tested every block in the window, and both factors grow with the file, so per-line cost rose 206 → 365 → 494 ns across these three sizes. The blocks are sorted by opener, so a forward cursor plus an active set replaces the scan; active's length is the nesting depth, single digits in real code. Per-line cost is now flat at ~93 ns — the pass is linear in covered lines, which is what it always claimed to be.

The debug-build keystroke→glyph ratchet is where this was found and where it matters. Its 2 000-line corpora, p50:

ratchet corpus, 2 000 linesbeforeafter
flat (every line at column 0)7.86 ms1.34 ms
nested, ~600 blocks14.69 ms2.10 ms

The nested row is the one to read: real source is indented, and at nearly two 60 Hz frames per keystroke it was a paramount-goal-#1 violation that no gate was watching — the ratchet's only corpus was flat, and a flat corpus has no blocks. keystroke_to_glyph_nested_within_baseline now covers it.

The renderer side — the active-block pick, run per frame per pane on the UI thread. This is the price of a zero-lag active guide: rather than publish an "is active" flag and rerun the worker on every cursor move, each renderer picks the enclosing block from the cursor row it already holds.

guide_active_pickValueNotes
cursor_row92 nsOne pick — the whole cursor-move cost.
120_row_viewport894 nsPick plus a walk of every visible row's marks; the full per-frame cost, and an over-estimate (it hashes each mark to defeat the optimiser).

Sub-microsecond against an 8.3 ms frame — the trade is paid for.

The walk itself, swept over file size, as the linearity guard:

guide_walk600 lines2400 lines9600 lines
indent_blocks3.40 µs12.8 µs55.1 µs

4× lines → ~4× time. This is why the walk is a stack rather than the "scan forward to find the end" formulation compute_indent_folds used before IG.5: that one is quadratic on deeply nested input, and this shape is where the difference shows.


IN.2 — predictive indent on the keystroke path (2026-08-15)

<CR>, o and O consult the tree-sitter indents.scm query before inserting a newline (auto-indent.md §4). New work on the path paramount goal #1 governs, so it is benched rather than reasoned about.

⚠️ Measured on Apple silicon (macOS), NOT the Ryzen/WSL2 box every other row in this document used. Not directly comparable to the tables above or below. Bench: crates/lattice-host/benches/indent.rs. Corpus: generated Rust, ~8 lines per function, queried at the file midpoint.

Measure80 lines800 lines3200 lines
indent_query — the per-<CR> cost8.39 µs8.82 µs9.49 µs
indent_method (100-fn file)ValueNotes
syntax8.72 µsTree path: scope lookup, bounded query, ancestor walk.
keep609 nsLexical copy — vim autoindent.
none434 nsFloor: resolve the option, return empty.

The feature costs ~8.1 µs over keep on the keystroke path — ~0.1% of a 120 Hz frame — and is flat in file size, which is the property that matters and the one that had to be earned twice.

Two bugs this bench caught, both invisible without it:

  1. The query ran over the whole file. A doc comment claimed it was bounded to the target line; the code passed source.len(). Measured 64 µs / 623 µs / 2.57 ms across the three sizes — linear, which puts a 36k-line file (dispatch.rs) near 30 ms per <CR>, four dropped frames. Scoping the query to the root child containing the cursor is sound rather than a trade — every node consulted is an ancestor of the position, and every ancestor but the root lies inside that child.
  2. Then the scope lookup was itself a linear sibling scan, leaving 29.7 µs at 3200 lines and still growing. This is the same bug TS.1 records one section above — a linear 0..child_count walk at the root of a large file — reintroduced in a different function eight weeks later. Binary search over the byte-ordered children flattened it to the 9.49 µs above. The recurrence is the argument for keeping both rows: the shape is easy to write and invisible without a size sweep.
indent_reparse (kept as a negative result)1.6 KB16 KB64 KB129 KB
Full re-parse + query188 µs1.9 ms7.6 ms15.4 ms

This row exists to justify an absence. The design specified a "stale snapshot ⇒ synchronous re-parse under a byte budget" branch; these numbers deleted it. 64 KB already exceeds half a 120 Hz frame here, and this hardware is the fast case. It would also have bought nearly nothing: the snapshot is stale precisely just after an edit, when the code is half-typed, and tree-sitter renders half-typed code as a bare ERROR node with no block structure — so the engine declines and the lexical bridge answers regardless. The branch would have spent milliseconds to return None. Anyone proposing to add it back to fix a stale-indent report should start here.


PO.3 — the hot-path grammar-trace gate (design §4, 2026-07-18)

The load-bearing artefact of PO.3: does instrumenting the sync grammar seam cost anything on the keystroke path when a user hasn't opted a plugin into tracing? The contract (docs/dev/architecture/plugin-observability.md §4) is zero-alloc, zero-arg-format when off — a single relaxed-atomic per-plugin gate load and a predicted-not-taken branch. The bench times the same down-n motion round-trip (the PH7.7d shape) in three states.

Bench: crates/lattice-plugin-host/benches/grammar_trace_gate.rs.

StateRelease medianNotes
grammar_seam_untraced~447 nsNo tracer wired — the pre-PO.3 baseline.
grammar_seam_trace_off~448 nsTracer wired, default Info gate (the common keystroke).
grammar_seam_trace_debug~551 nsThe plugin raised to Debug — every call times + enqueues a record.

Read: trace-off vs. untraced is ≈ +1 ns (~0.3 %) — inside run-to-run noise, i.e. the gate load + not-taken branch is all the off-state pays, exactly the §4 contract. The exit criterion ("tracing off shows ≈0 delta vs. the ratchet") holds: the grammar_round_trip ratchet (250 µs debug ceiling) is untouched. Turning a plugin up to Debug adds ~104 ns (an Instant pair + a bounded-ring push + event publish) — never on the default path, and still ~9× under the 5 µs p99 seam budget even while tracing. Formatting + buffer append stay off-thread on the PO.4 drain (not in this measurement).


PH7.8 — event/hook delivery (the §7 "major-mode event handler" gate, 2026-07-12)

The off-keystroke async cost a plugin hook pays per delivered event: the native EventBus sink pushes each matched Event into the plugin's actor channel (lock dropped) → the EventActor projects Event → WIT (boundary_event) and drives the guest on-event export (async canonical-ABI call). This is the §7 major-mode event handler < 250 µs p99 commitment. Measured through the events-guest fixture's no-op handler 4 (DocumentChanged, no fs), which isolates the dispatch path from any handler work.

Marshalling (PH7.8a) — benches/boundary.rs:

ConversionValueNotes
Event::DocumentSaved round-trip~23 nsThe common path-bearing hook; native ↔ WIT both ways.
Event::SelectionsChanged round-trip~tens of nsCarries the selection-set mirror (PH7.3b).

End-to-end delivery — CI gate tests/perf_ratchet.rs (event_handler_stays_within_ceiling):

MeasureValueNotes
Debug mean / delivery (ratchet)~3.75 µsMean over 1 000 deliveries (the actor drains a channel, so per-call samples aren't available); under cargo test (debug).
CI gate ceiling2 ms meanOrders of magnitude above the µs-scale debug dispatch, well under a per-delivery re-instantiation (~200 µs each) or an O(payload) marshalling blowup.

Read: event delivery decomposes into the ~23 ns marshalling + one async guest on-event call (≈ the PH7.3d ~437 ns typed call) + a sub-µs channel hop — so ~3.75 µs debug mean is ~70× under the §7 250 µs budget in debug, before release optimization. No dedicated criterion bench: the per-call cost is fully characterized by the already-benched components (the picker/completion precedent). The handler's own compute is bounded separately by the fuel-primary off-key budget (PluginBudget::event: 100M fuel ≈ ~10 frames; the epoch is a generous ~1 s backstop because an event handler runs on the async linker and may legitimately await a host-services call — unlike the sync grammar Reflex budget).


TC.1 — sticky-context resolution (2026-08-16)

The one part of tree-sitter context that runs on the keystroke path: resolve_context answers "which scopes does this pane pin right now" from the cached scope set, and the host calls it per pane on every pane-inputs publish — which a cursor move is. Everything else in the feature is off-thread (the plugin's query, once per reparse) or version-gated (the worker's row build, skipped when the resolved list is unchanged).

corpustime
100 scopes, depth 5204 ns
5 000 scopes, depth 202.66 µs
50 000 scopes, depth 2021.8 µs

Linear at ~0.44 ns/scope — the shape is a scan over the scope list plus a sort of the small enclosing subset (O(n + d log d)). The design fragment originally claimed O(log n + depth); that was wrong, and the number is why the linear form is kept: 21.8 µs at the pathological end is 0.26% of a 120 Hz frame, and a 3k-line source file sits nearer 1 µs.

This is the ratchet. A change that makes the per-call cost superlinear in the scope count fails here rather than in review, and the fix at that point is an augmented interval structure, not a reordering.


TC.3b — sticky-context row build (2026-08-18)

The off-keystroke half. When the resolved line list CHANGES, the cells worker builds one row per pinned line: a highlight_lines call plus a cell materialisation each. A cursor moving within one scope changes nothing and is skipped entirely, so this measures the rebuild, not the steady state.

Measured against strip depth over a highlighted 5 000-line Rust document, with the pinned lines spread through the file so the far ones are genuinely outside the built chunk — the case the worker exists for:

strip depthfull pane recompute
0 rows (baseline)1.61 ms
3 rows1.70 ms
10 rows1.90 ms

~29 µs per pinned row, linear in depth and independent of file size. That is the number the design's central claim rests on: the worker can build a row for any line whether or not a chunk covers it, and doing so costs a highlight call rather than a matrix rebuild. A regression to re-deriving the matrix per row shows here as super-linear growth against depth.

The default strip is unbounded in depth (max-lines = 0) and bounded by max-viewport-fraction = 33, so a 30-row pane tops out near 10 rows — the right-hand column above is the realistic worst case, not the typical one.


TC.10 — the whole-buffer structural query (2026-08-18)

The plugin's only expensive call: one run-query-ranges per reparse, off thread. Rust, release wasm, measured on this repo's dispatch.rs and multiples of it.

linesrun-query (before)run-query-ranges (after)
1 00025 ms4.6 ms
2 50083 ms6.0 ms
5 000287 ms9.0 ms
10 0001.18 s15 ms
20 000TRAP28 ms
36 000TRAP52 ms
100 000—135 ms
400 000—534 ms

The old shape was superlinear because run-query mints one node RESOURCE per capture — a host table entry with a snapshot bump and a guest-side drop — and a whole-file structural query has tens of thousands. Past ~20k lines it exceeded the producer's epoch deadline and trapped, which quarantines the plugin for every buffer until reload.

The ranges form is linear at ~1.4 µs/line with no cliff, which is what let max-file-lines move 5 000 → 100 000. The ratchet here is the shape: a return to per-capture resource churn shows as super-linear growth long before it reaches a trap.


PH7.9 — decoration production (the §7 "status/gutter segment update" gate, 2026-07-13)

The off-render-path cost a decoration provider pays per trigger (edit / scroll / diagnostic change): the host projects the decoration-context, calls the guest gutter-decorations producer (async canonical-ABI), and converts the returned list<gutter-decoration> to native before caching it. This is the §7 status / gutter segment update < 50 µs p99 commitment. The producer is the completion PH7.6 fork — the sync Mode::gutter_decorations trait is read per frame, so a WASM mode can't satisfy it inline; the plugin produces off-trigger and the renderer reads the cache (never WASM on the tick).

Marshalling (PH7.9a) — benches/boundary.rs:

ConversionValueNotes
GutterDecoration::Diff round-trip~tens of nsPer-line scalar (line + kind); native ↔ WIT both ways.
GutterDecoration::Severity round-trip~tens of nsPer-line scalar (line + level).

End-to-end produce — CI gate tests/perf_ratchet.rs (decoration_produce_stays_within_ceiling):

MeasureValueNotes
Debug median (ratchet)~63 µsWarm produce round-trip through the decorations-guest fixture (project ctx → guest producer → convert; no walk); cargo test (debug).
CI gate ceiling5 msOrders of magnitude above the µs-scale debug produce, well under a per-trigger re-instantiation or an O(payload) blowup.

Read: produce decomposes into the ~ns context projection + one async guest producer call (≈ the PH7.3d typed call) + per-decoration marshalling (~ns) + a sub-µs channel hop — the ~63 µs debug median is the canonical-ABI lift/lower under debug, far under budget once release-optimized. The producer's own compute is bounded by the fuel-primary PluginBudget::decoration (100M fuel ≈ ~10 frames; epoch a generous ~1 s backstop — a decoration producer runs on the async linker and may await host-services, e.g. a git-gutter source reading the repo). NB: PH7.9 is validation-only — the renderer-reads-the-cache wiring is the Phase-8 boot-wiring step; the gate here is on the producer, which is what §7 budgets.


Full-suite baseline — Apple M1 Pro (2026-07-12)

First full-suite run captured natively on the local macOS dev machine, not the WSL2 / Ryzen 7 9700X box the rest of this document is pinned to. This is a new, independent baseline — the numbers here are NOT a regression comparison against the Ryzen sections below and must not be read as one (a slower number here is not a regression; different silicon, different OS, and in several cases five weeks of code drift separate the two). It exists so that:

  1. future macOS runs have a same-hardware floor to regress against (the same role the Ryzen sections play for the dev box), and
  2. we finally have the cross-hardware cross-validation the hardware caveat's point #4 put on the wish list — a second machine that tells us where the WSL2/Ryzen numbers mislead.

Two per-feature macOS sections already exist and were re-confirmed in this same run rather than duplicated here: scope_toward (TSM.5) and the dashboard benches (DB.7). See those sections below.

Environment

  • Date: 2026-07-12
  • Host: Apple M1 Pro (8P+2E, 10-core), 32 GB unified memory, macOS 14.5 (23F79) — laptop-class, native (no VM layer).
  • Toolchain: Rust 1.94.0 stable (aarch64-apple-darwin).
  • Build profile: bench (opt-level = 3), criterion default mode (100 samples), reported number is the median estimate.
  • Not pinned. No CPU-governor lock (macOS gives none), laptop DVFS + thermal throttling apply, and the machine was not otherwise quiesced. Treat ±10-15 % as noise, same posture as the WSL2 box.
  • Reproduce: cargo bench --workspace (plus the two feature-gated / fixture-gated suites called out under "Coverage gaps" below).

Cross-hardware headline — three effects, not one

Running the same suite on native Apple Silicon vs. the WSL2/Zen 5 box separates three effects the dev-box numbers conflate:

  1. The async round-trip path is ~10× faster native than on WSL2. apply_edit_round_trip and dispatch_round_trip (small buffer) are mailbox + oneshot + block_on round-trips — dominated by scheduler / syscall latency, not by any buffer work. On WSL2 that costs ~77-91 µs; native macOS lands at ~7 µs. This is the most important finding of the run: the §8.2 "apply-edit round-trip < 100 µs / today 77 µs" and "dispatch round-trip 79-91 µs" rows are WSL2-scheduler-bound, not code-bound — real user-facing round-trip latency has ~10× more headroom under the keystroke budget than the dev-box numbers imply. The doc already suspected WSL2 syscall overhead ("~5-15 %"); the true figure on this path is an order of magnitude, not a few percent.

  2. Pure single-thread compute is ~1.5-2× slower on the M1 Pro than the Zen 5 Ryzen. A 5 GHz Zen 5 core is simply faster per-clock on tight scalar/branch-heavy loops than an M1 Pro P-core. Every allocation-free microbench moves the same direction: status-segment update 56 ns → 92 ns (1.6×), snapshot publish 95 ns → 122 ns (1.3×), LSP publishDiagnostics decode 1.50 µs → 3.03 µs (2.0×), utf-16 CJK column 21 ns → 33 ns (1.6×), search literal scan / 200k 749 µs → 1.41 ms (1.9×). This confirms the hardware-caveat direction (typical non-dev-box hardware is slower on scalar work) but puts a high-end laptop at the ~1.5-2× end, not 2-5×.

  3. Memory-bandwidth-bound ops are faster on the M1 Pro. Unified LPDDR5 + wide load/store wins where the work is "stream a big rope": open 100 MB 74 ms → 55 ms (0.74×), d_whole/50000 ~3 ms → 2.38 ms (but the Ryzen d_whole/50k number is itself flagged as host-drift-affected below, so read that one loosely).

What this means for paramount goal #1. Every keystroke-path row is far inside the one-frame ceiling (8.3 ms @ 120 Hz) on this machine too, and the round-trip path has more headroom natively than the WSL2 numbers show. The compute-bound rows being ~1.5-2× slower than the dev box is expected and still leaves large margin. Nothing here threatens a §8.2 commitment; the value is calibration, not alarm.

§8.2 commitments — M1 Pro readings

ratio is M1 Pro ÷ Ryzen-doc; > 1 means slower on the M1 Pro. Ryzen values are this document's current "Today" numbers.

§8.2 rowRyzen (doc)M1 ProratioNote
Snapshot load (load_full)16 ns13.2 ns0.82×M1 faster
Snapshot load (Cache::load, steady)290 ps451 ps1.55×still sub-ns
Snapshot publish standalone95 ns122 ns1.28×Arc::new + atomic
Status segment update56 ns92 ns1.64×scalar compute
Apply-edit round-trip77 µs7.6 µs0.10×WSL2 scheduler overhead — effect #1
Dispatch round-trip (small)79-91 µs6.9 µs0.09×same
Frame render TUI 80×24 (hl+compose)~199 µs~68 µs0.34×54 µs viewport hl + 13 µs compose
Frame render TUI 200×60~307 µs~171 µs0.56×139 µs + 32 µs
Open 100 MB (rope)74 ms55 ms0.74×bandwidth-bound; M1 faster
Search literal worst-case 200k749 µs1.41 ms1.88×scalar scan; M1 slower
Tree-sitter incremental reparse (1600 / 16k)293 µs / 1.46 ms576 µs / 3.98 ms~2.0× / 2.7×scalar tree walk
Highlight span cache hit21 ns——bench dropped from current render suite
LSP framing parse (Content-Length)68 ns105 ns1.54×
LSP encode didChange183 ns294 ns1.61×
LSP decode publishDiagnostics1.50 µs3.03 µs2.02×
LSP utf-16 column (CJK line)21 ns33 ns1.59×

Runtime / actor

The round-trip rows are effect #1 — WSL2-bound on the dev box, ~10× cheaper native.

BenchM1 ProRyzen (doc)ratioNote
snapshot_publish_standalone/{10,1k,50k}122 / 124 / 123 ns~95 ns1.29×constant across sizes (O(1) clone), as on Ryzen
apply_edit_round_trip/{10,1k,50k}7.6 / 9.5 / 7.7 µs~77 µs0.10×scheduler-bound; native ≫ WSL2
dispatch_round_trip/{10,1k,50k}6.9 / 24.0 / 1061 µs79 / 91 / 575 µs0.09× / 0.26× / 1.84×small-buf = scheduler (M1 faster); 50k = the motion walk itself (M1 slower, scalar)
snapshot_publish_via_apply_edit/{10,1k,50k}7.7 / 7.5 / 10.2 µs77 µs0.10-0.13×envelope, tracks round-trip
snapshot_load/load_full13.2 ns~16 ns0.82×at floor
snapshot_load_cached/steady451 ps~290 ps1.55×sub-ns; renderer read path
snapshot_post_publish_read/{10,1k,50k}92.9 / 13.5 / 13.8 ns71 / 17 / 20 ns~
status_segment_update92.0 ns~56 ns1.64×
event_filter_publish/kinds-only/{16,64,256}0.96 / 3.7 / 16.3 µs——not in doc; O(subscribers) fan-out
event_filter_publish/path_glob/{16,64,256}1.6 / 6.1 / 26.6 µs——glob-match adds ~1.6× over kinds-only

Core — buffer / document hot-path

BenchM1 ProRyzen (doc)ratio
buffer::insert_at_origin/{10,1k,100k}2.33 µs / 1.74 µs / 140 µs1.71 µs / 1.14 µs / 66 µs~1.4-2.1×
buffer::insert_at_middle/{10,1k,100k}2.43 / 2.13 / 143 µs1.96 / 1.96 / 66 µs~1.2-2.2×
buffer::delete_one_byte/{10,1k,100k}2.60 / 1.94 / 145 µs2.14 / 1.53 / 67 µs~1.2-2.2×
buffer::position_byte_round_trip/{10,1k,100k}1.12 µs / 364 ns / 214 ns863 / 372 / 323 ns~ / flat / 0.66×
buffer::open_large/{10mb,100mb}4.25 ms / 54.9 ms— / 74 ms0.74× (bandwidth win)
buffer::clone_vs_text/clone/{10,1k,100k}9.8 / 9.8 / 10.0 ns~7.7 ns1.3× (Arc bump; flat, as designed)
buffer::clone_vs_text/as_string/100k677 µs211 µs3.2× (falsification anchor; scalar copy)
input_edit_construction3.52 ns1.82 ns1.93×
document_read_p99_us::viewport_walk/{10,1k,100k}4.89 / 10.1 / 10.8 µs3.2 / 6.8 / 7.1 µs~1.5×
document_edit_p99_us::insert_at_middle/{10,1k,100k}2.52 / 2.23 / 158 µs2.2 / 1.5 / 76 µs~1.1-2.1×
document_edit_p99_us::set_selections_motion/*15.2 / 17.3 / 15.2 ns~4.2 ns3.6× — still trivial; scalar field write + version bump

Grammar — motions / operators (all Reflex-class)

MotionM1 Pro (10 / 1k / 50k)Ryzen (doc)
word_forward408 ns / 14.8 µs / 1.02 ms279 ns / 9.86 µs / 523 µs
word_backward1.56 µs / 3.11 µs / 405 µs1.25 µs / 1.94 µs / 108 µs
word_end1.35 µs / 2.85 µs / 394 µs1.13 µs / 1.59 µs / 103 µs
first_non_blank (50k indented)342 µs226 µs
word_forward_count (50 in 100×)1.01 µs611 ns
find_char_forward (900-char line)445 ns279 ns

Motions are scalar character-class scans (effect #2): uniformly ~1.5-4× the Ryzen numbers, all still ≪ the 2 ms Reflex budget. The 50k cases (0.4-1.0 ms) are the memchr-optimization candidates the doc already flags.

OperatorM1 Pro (10 / 1k / 50k)Ryzen (doc)Note
dw5.58 µs / 20.4 µs / 1.35 ms4.97 µs / 17.3 µs / 670 µs50k ~2×
dd6.15 µs / 35.4 µs / 1.78 ms5.43 µs / 15.9 µs / 840 µs
d_whole5.55 µs / 85.8 µs / 2.38 ms5.15 µs / 20.6 µs / ~3 msRyzen 50k is host-drift-flagged; M1 2.38 ms is a clean native number
yw2.44 µs / 20.9 µs / 1.09 ms6.16 µs / 13.3 µs / 890 µs
cw5.62 µs / 21.2 µs / 1.36 ms4.93 µs / 13.4 µs / 687 µs
diw6.98 µs / 7.23 µs / 434 µs5.83 µs / 3.84 µs / 227 µs
di_paren (deep arg list)17.8 µs8.79 µs2.0×
replace_char/normal/{10,1k,50k}5.69 / 6.29 / 418 µs—not in doc

d_whole/50000 is the row the doc spends the most ink on (WSL2 host-state drift, 1.23 ms → ~3 ms unreproducible). The clean native M1 Pro number is 2.38 ms — the only operator over the 2 ms Reflex budget here, same as on the dev box, and for the same reason (it deletes the entire 50k-line buffer; ropey remove bandwidth dominates). A native regression threshold of ~5 ms is the watch line.

Syntax — highlight / reparse / folds

BenchM1 ProRyzen (doc)Note
highlight::rust/{10,200,2000}172 µs / 3.57 ms / 37.7 ms142 µs / 2.93 ms / 38 msfull-buffer; ~1.2×
highlight::rust_viewport/{24,60,120}54.3 / 139 / 264 µs184 / 261 / 359 µs0.30-0.74× — M1 faster on the renderer's real call shape
highlight::python/{10,200,2000}53.7 µs / 1.15 ms / 12.0 ms—not in main table
highlight::markdown/{10,100,500}365 µs / 3.63 ms / 18.3 ms—injection-recursion path
tree_edit_single_char/{10,200,2000}10.4 µs / 229 µs / 2.64 ms4.6 µs / 167 µs / 2.66 mslarge-tree edit flat vs Ryzen
reparse_incremental/{10,200,2000}820 µs / 576 µs / 3.98 ms586 µs / 293 µs / 1.46 ms~2× (scalar); still beats full reparse at scale
reparse_full_baseline/{10,200,2000}254 µs / 3.11 ms / 31.1 ms199 µs / 2.47 ms / 23.7 msfalsification anchor; 2000 blows the 16 ms frame budget on both machines — why incremental matters
folds::compute_indent/{10,200,2000}2.95 / 47.7 / 496 µs1.9 / 30 / 310 µslinear; ~1.6×
folds::compute_markdown/{10,100,500}1.51 / 10.2 / 51.3 µs1.0 / 6.7 / 30 µs
folds::compute_syntax_rust/{10,200,2000}76.6 µs / 1.53 ms / 15.7 ms64 µs / 3.7 ms / 286 ms200-fn and 2000-fn markedly faster on M1 — likely code drift since the 2026-06 folds run, not pure hardware
scope_toward/{fwd,back}_start1.67 / 1.68 ms(see TSM.5)re-confirmed this run; flat

The rust_viewport and large compute_syntax_rust rows moving the opposite direction from the scalar microbenches is a flag that those Ryzen numbers predate this run by ~1-5 weeks and reflect code changes, not just silicon — do not read those ratios as hardware-only.

Renderer — TUI compose + keymap + GPUI prepaint

BenchM1 ProRyzen (doc)Note
render::frame_24_lines/{10,200,2000}13.4 / 13.4 / 13.9 µs15 µs (200)flat across fn-count (compose is O(viewport))
render::frame_60_lines/{10,200,2000}32.2 / 32.1 / 32.2 µs46 µs (200)0.70×
render::frame_120_lines/{10,200,2000}37.3 / 36.5 / 36.5 µs90 µs (200)0.41×
keymap_trie_lookup_{single,two,three}_chord23.4 / 60.5 / 76.3 ns—not in doc; per-keystroke dispatch
keymap_handle_lookup_with_{one,two,three}_minors1.78 / 1.93 / 2.21 µs—full layered lookup incl. minor modes
dispatch_translate_full_operator_motion168 ns—grammar translate
editor_element_frame_pre_paint/{24,60,120}14.4 / 35.7 / 71.5 µs90.1 µs (120, post-B.2)0.79× at 120 — GPUI prepaint
editor_element_frame_with_inlays/{24,60,120}20.5 / 51.6 / 103 µs118.6 µs (120)0.87×
editor_element_frame_with_overlays/{24,60,120}14.5 / 35.7 / 70.6 µs89.9 µs (120)0.79×

TUI compose and GPUI prepaint are both faster on the M1 Pro — the renderer paths lean on cache-resident row/attribute construction where Apple Silicon's memory subsystem helps, not on the tight scalar loops where Zen 5 wins.

Host workers — cells / overlay / dispatch-publish / diff / fold / preview

Most of these come from dated 2026-06 slice sections; the bench bodies may have drifted, so these are recorded as a fresh M1 baseline rather than compared row-by-row.

BenchM1 Pro
cells_worker_full_build/{100,1k,5k}228 µs / 1.01 ms / 250 µs
cells_worker_incremental_build/{100,1k,5k}161 / 332 / 454 µs
cells_worker_incremental_highlighted/{100,1k,5k}193 / 438 / 466 µs
cells_worker_windowed_build/{5k,20k,50k,100k}1.49 / 1.86 / 1.83 / 1.44 ms — flat (O(window), not O(file), as designed)
cells_worker_cache_hit/{100,1k,5k}46.8 / 46.8 / 47.4 ns
display_edit_path/{100,5k,100k}149 / 372 / 375 µs
overlay_worker_cache_hit/{24,60,120}39.3 / 47.9 / 39.3 ns
overlay_worker_recompute_on_scroll/{24,60,120}16.4 / 21.3 / 30.9 µs
overlay_worker_stale_snapshot_hold/{24,60,120}6.81 / 6.81 / 8.58 µs
dispatch_publish/steady_state11.4 µs
dispatch_publish/{mutated_modes,mutated_all,unmemoised}13.1 / 12.8 / 14.1 µs
dispatch_publish/keystroke_publish_{2000,100000}12.5 / 12.8 µs
overlay_only_at_n_hunks/{0,10,100,1000}185 ns / 574 ns / 5.15 µs / 267 µs
hunk_source_compute_pure/{0,10,100,1000}14.9 ns / 283 ns / 2.02 µs / 17.6 µs
fold_identity_hash22.1 ns
modeline_build/{0,8,32,128}1.08 / 1.91 / 4.16 / 13.9 µs
autoread_watch_set/fingerprint/{10,100,1000}1.53 / 28.5 / 439 µs
autoread_watch_set/bound_uncapped/{10,100,1000}1.49 / 14.6 / 591 µs
clipboard_store_yank/{register_only,mirror_on,system_always}101 / 118 / 137 ns
preview_reseat_same_buffer32.4 ns
preview_enter_exit17.0 µs
activate_swap_baseline1.30 µs
pane_group_no_group / pane_group_identity_propagation1.39 ns / 25.2 ns
hunk_row_map_p99_us519 ns
dashboard_creation / dashboard_idle_tick613 µs / 875 ns — see DB.7; re-confirmed

dispatch_publish/steady_state at 11.4 µs (vs the doc's 3.23 µs on Ryzen, −52 % vs its own unmemoised) is the clearest allocator-bound outlier — the Mutex<PublishCache> lock + version compares + Arc clones cost more under the macOS system allocator. Still ~3 µs of net work on a no-op publish and far under any frame budget, but it is the one host-path row where the M1 Pro is notably (~3.5×) slower than the dev box.

Diff subsystem

BenchM1 Pro
diff_two_way/5k_x_80_1pct_edit/{Histogram,Myers,MyersMinimal}12.0 / 12.0 / 12.0 ms
diff_two_way/50k_x_200_0.1pct_edit/Histogram1.18 s
diff_three_way/5k_x_80_two_sides_edited25.6 ms
recompute_blocking/lines/{1k,5k,50k}553 µs / 11.6 ms / 1.11 s
recompute_blocking_three_way/lines/{1k,5k,50k}1.22 ms / 24.7 ms / 2.31 s

The 50k-line diff rows are the heaviest in the whole suite (1-2 s). These run off the keystroke path (async recompute on save / hunk refresh), but they are the standout candidates for a future large-file diff-cost pass on any hardware.

Multibuffer

BenchM1 ProRyzen (doc, M.2.c)
multibuffer_motion/next_excerpt_start/{50,500,5000}114 ns / 875 ns / 9.56 µs80 ns / 870 ns / 7.9 µs
multibuffer_motion/next_file_boundary/{50,500,5000}320 ns / 1.61 µs / 20.0 µs120 ns / 1.5 µs / 12 µs
multibuffer_compose_50_excerpts233 µs(CI gate ≤ 200 µs / 50 excerpts)
multibuffer_translation_rebuild/excerpts/{100,1000}249 µs / 1.17 ms(gate ≤ 2000 µs / 20k rows)
multibuffer_append_excerpts/{50,500,5000}38.7 / 65.9 / 306 µs—
multibuffer_source_edit/excerpts/{100,1000}24.7 / 24.8 µs—
actor_max_probe_gap_ms_during_1k_scan12.9 ms—
project_search/first_batch_p99_ms_1k_files126 ms—

multibuffer_compose_50_excerpts at 233 µs is just over the architecture-§7 CI gate of 200 µs / 50 excerpts on this machine — worth noting as the one place a documented gate is exceeded natively, though the gate was set on the dev box and compose is scalar-walk heavy (effect #2). Re-baseline the gate per-machine or confirm on the dev box before treating it as a real breach.

Terminal

BenchM1 Pro
term_snapshot_build/default_10k_2005.24 ms
term_snapshot_build/stress_50k_40067.8 ms

The doc's terminal Cargo note pins CI at p99 ≤ 2.0 ms at 10k×200; the median here is 5.24 ms, ~2.6× over that pin. This is a laptop-native number on a bench the doc's §8.2-adjacent pin was written for the dev box — flag for review, not an automatic breach.

Plugin host (WASM Component Model)

Backs paramount goal #2 (per-call WASM overhead < 500 ns p99).

BenchM1 ProNote
boundary_app_effect_round_trip8.15 nsin-process effect encode
boundary_args_round_trip79.9 ns
boundary_raw_candidate_round_trip87.4 ns
boundary_effect_round_trip159 ns
boundary_picker_outcome_round_trip33.7 ns
boundary_picker_candidate_with_marginalia_round_trip412 nsunder the 500 ns typed-call budget
boundary_routing_payload_round_trip31.6 ns
document_get_text_range_one_line492 ns
plugin_instantiate_noop2.12 µswarm store instantiate
plugin_compile_instantiate_noop316 µscold compile + instantiate
load_50_plugins_warm_cache15.9 ms~318 µs/plugin
instantiate_50_plugins96.9 µs~1.9 µs/plugin

All the per-call boundary round-trips clear the < 500 ns typed-call budget on this machine. The trampoline guest-fixture bench did NOT run — see Coverage gaps.

LSP / config / completion / picker

BenchM1 ProRyzen (doc)
lsp::framing::parse_header_block105 ns68 ns
lsp::encode::did_change294 ns183 ns
lsp::decode::publish_diagnostics3.03 µs1.50 µs
lsp::decode::small_response897 ns—
lsp::encode_decode::hover_request1.77 µs—
lsp::position::utf16_cjk_line33.4 ns21 ns
lsp::position::utf16_to_byte_cjk62.6 ns—
lsp::position::utf8_passthrough564 ps—
lsp::logging::{log_info,log_trace_off,log_trace_on}210 ns / 8.82 ns / 241 ns—
lsp_mode::activate_deactivate/*~8.07 µs—
config::get_bool_via_handle22.5 ns—
config::resolved_get_typed14.2 ns—
config::resolve_into_10_layers7.78 µs—
annotate_pipeline_1000_3stage195 µs167 µs (MARG.4)
keybinding_annotator_100036.7 µs59 µs
styled_marginalia_columns_1000140 µs142 µs
styled_picker_columns_1000179 µs183 µs

OR.7 — the async completion fan-out. A plugin completion source's cost is the guest crossing, already covered by plugin-host/benches/completion.rs (generate_warm); the fan-out adds one spawn_on_lsp_runtime per enabled async source per round. Both sit off the keystroke path by construction — generate is awaited on the shared runtime and only the drain touches the popup — so the keystroke budget is unchanged and no new bench guards it. What is on the keystroke path is unchanged native work: match_and_rank over the accumulated raw set, which the annotate_pipeline_* rows above already characterise. A source returning a large candidate set (org-roam hands over every node once per popup-open) therefore shows up in those rows, not in a new one. | picker::open_inline/{100,500,5000} | 33.0 / 190 / 2738 µs | ~1.94 ms (5000, post-8) | | picker::refilter/n=5000,query={"","f","file_"} | 1.57 / 2.33 / 2.73 ms | 801 µs / 1.43 / 1.61 ms (post-8) | | picker::mru_snapshot/{100,500,5000} | 14.4 / 74.9 / 830 µs | ~520 µs (5000, post-8) | | picker::bonus_of | 8.66 ns | — |

The picker refilter hot path (per-keystroke) is ~1.7-2× the Ryzen post-slice-8 numbers — pure fuzzy-match scalar work (effect #2). The marginalia column-layout rows (styled_*) are essentially identical across machines (allocation-bound, not scalar-throughput-bound).

Coverage gaps + broken benches (this run)

Two benches did not produce numbers. Both are pre-existing and platform-independent (they would fail on the dev box too); neither is a macOS artifact, and fixing them is out of scope for a bench run — recorded here so the gap is visible:

  1. lattice-mode activation_resolver panics. The fixture registers mode IDs like global-minor-0, but ModeRegistry:: register now rejects IDs without a recognised suffix (MissingModeSuffix(ModeId("global-minor-0")) at activation_resolver.rs:45). The bench has been stale since that validation landed. It aborts cargo bench --workspace, so the full-suite run must be split per-crate (or the bench fixed / --excluded) to complete. tick_callback (the crate's other bench) runs fine: tick_callback_run_all/{0,1,8,32} = 9.67 ns / 71 ns / 266 ns / 891 ns.
  2. lattice-plugin-host trampoline fixture won't build. The WASM guest fixture fails to compile for wasm32-wasip2 (could not compile bitflags … E0463) even with the target installed, so PH7.3d trampoline test/bench skips. The other plugin-host benches (boundary, instantiate) run and are reported above.

The GPUI frame bench requires --features window,bench-internals and so is skipped by a plain cargo bench --workspace; it was run separately (editor_element_frame* above).


TSM.5 — scope_toward structural-motion tree walk (2026-07-08)

Tree-sitter structural motions, slice TSM.5 (design: ../architecture/treesitter-motions.md; slice plan: slice-plans/archive/treesitter-motions.md, TSM.0–TSM.5). scope_toward backs the 16 ]f [f ]F [F ]c [c ]C [C ]a [a ]A [A ]l [l ]L [L-style motions and runs on the core/actor thread on a deliberate keypress — never in Render::render, never per-frame (paramount #1).

Bench file: crates/lattice-syntax/benches/scope_toward.rs. Run: cargo bench -p lattice-syntax --bench scope_toward. Fixture: 2000 top-level Rust functions (fn fN() { let x = N; }), queried for @function.outer from the file midpoint.

BenchMedian timeNotes
scope_toward/fwd_start~1.717 msForward/Start from the midpoint — scans [cursor, EOF) only, i.e. the second half of the file (~1000 functions).
scope_toward/back_start~1.726 msBackward/Start from the same midpoint — scans [0, cursor) only, i.e. the first half of the file. Matches fwd_start closely, as expected: both directions scan a symmetric half of the fixture.

Re-confirmed 2026-07-12 (same M1 Pro machine, full-suite run — see the "Full-suite baseline — Apple M1 Pro (2026-07-12)" section above): fwd_start 1.670 ms, back_start 1.680 ms. Flat within noise (~3 % under the recorded floor); the floor and the ~5 ms regression envelope both hold. Not bumped — a routine-run confirmation, not a deliberate perf change (see the snapshot-not-a-moving-record note at the top).

The byte-range restriction is the point of this bench. SyntaxSnapshot::scope_toward calls QueryCursor::set_byte_range before running the textobjects.scm query: Forward restricts the cursor to cursor_byte..source.len(), Backward to 0..cursor_byte+1. Without that restriction the query would walk the whole 2000-fn tree on every motion, regardless of direction; with it, each call only ever visits matches in one half of the file. The near-identical fwd/back numbers here are exactly what that symmetric halving predicts — the query cost scales with distance scanned, not total file size.

⚠️ Hardware note for this row only. Captured on the local macOS (Apple Silicon M1 Pro) dev machine, NOT the WSL2/Ryzen 7 9700X box the rest of this document's numbers are pinned to (see the hardware caveat above). First-recorded floor with no same-hardware predecessor; not directly cross-comparable with the rest of this document. 2000 functions is a stress fixture (a real "large file" is closer to a few hundred), so ~1.7 ms here is a worst-case ceiling, not a typical per-keypress cost — comfortably inside a deliberate-keypress budget (not a per-frame one) even at this size.

Regression envelope: either row past ~5 ms (≈3× the recorded floor) signals the byte-range restriction stopped bounding the scan — check that set_byte_range is still applied before cursor.matches(...) in SyntaxSnapshot::scope_toward.

CR.4 — plugin dashboard section benches (2026-08-22)

Contributable registries, slice CR.4 (design: ../architecture/contributable-registries.md §3.2; slice plan: slice-plans/archive/contributable-registries.md). CR.4 put a synchronous guest call on the actor thread, inside Editor::compose_dashboard_sections. The design argues that is acceptable because composition is a LatencyClass::Display action — :dashboard, startup, or a DB.6 recompose — never per-keystroke and never per-frame. These are the numbers that make the argument falsifiable instead of a paragraph.

Bench file: crates/lattice-plugin-host/benches/dashboard_section.rs. Run: cargo bench -p lattice-plugin-host --bench dashboard_section. Skips (rather than fails) when the dashboard-guest fixture was not built.

BenchMedian timeNotes
dashboard_section_render_ns~1.77 µsOne render(&ctx) on a live plugin section — what a compose pays per plugin section. Against DB.7's dashboard_creation (~571 µs for the whole page) that is ~0.3 %: a plugin section is a rounding error on the compose it joins. That ratio is the number to watch, not the absolute.
dashboard_section_spawn_ns~237 µsDeclaring + instantiating one guest per section at load. Paid once, on the loader's off-boot-thread task, so it cannot delay boot — recorded so a regression there is visible too.

This bench found a bug, which is the case for writing it. The first run reported dashboard_section_render_ns at 9.37 ns. A wasm call cannot be 9 ns — that was a poisoned section's early return being measured, not a render. CR.4 had armed the per-call fuel budget once at instantiate (correct for a declare-once seam like config / help, wrong for one called on every compose), so the section worked for 1173 composes and then trapped permanently. Fixed in 5cb57b69; the same shape turned out to be present in CM.6b's error-parser and was fixed in b2a4e992.

The lesson generalises past this bench: a suspiciously fast number is a result, not a win. Nothing else in the suite would have caught it — the seam's own tests render two or three times and pass against the broken build.

DB.7 — dashboard creation-time + idle-frame benches (2026-07-03)

Dashboard feature, slice DB.7 (design: ../architecture/dashboard.md §13; slice plan: slice-plans/dashboard.md). The dashboard is not on the keystroke path — it composes once at creation (:dashboard, startup, or a dashboard.sections/dashboard.source/ui.nerd_fonts recompose), never per keystroke — so there is no keystroke→glyph bench here, per design §13. Coverage is the two assertions §13 calls for instead.

Bench file: crates/lattice-host/benches/dashboard.rs. Run: cargo bench -p lattice-host --bench dashboard.

BenchMedian timeNotes
dashboard_creation~571 µsCold Editor::do_open_dashboard from a freshly booted, not-yet-opened editor — buffer creation + default-section compose + HelpContent seed + branding provider registration. Fires once per launch (or :dashboard), never per keystroke.
dashboard_idle_tick~906 nsrun_tick_pending on an editor with the dashboard already open and nothing new published — the idle-frame cost.

Re-confirmed 2026-07-12 (same M1 Pro machine, full-suite run — see the "Full-suite baseline — Apple M1 Pro (2026-07-12)" section above): dashboard_creation 613 µs (+7 %, well inside the ~1.5 ms envelope), dashboard_idle_tick 875 ns (flat, under the ~2 µs envelope). Both floors hold. Not bumped — a routine-run confirmation, not a deliberate perf change.

⚠️ Hardware note for this row only. Captured on the local macOS (Apple Silicon) dev machine, NOT the WSL2/Ryzen 7 9700X box the rest of this document's numbers are pinned to (see the hardware caveat above). These are first-recorded floors with no same-hardware predecessor to compare against, so the absolute numbers aren't cross-comparable with the rest of this document — re-baseline on the primary dev box before relying on them for cross-feature comparison. The regression-detection principle (compare future runs against these, on the same machine) still holds.

Envelope for future regressions: dashboard_creation past ~1.5 ms (≈2.6× the recorded floor) or dashboard_idle_tick past ~2 µs suggests composition work leaked onto a path it shouldn't be on (e.g. a section doing I/O, or the recompose-in-place path running when nothing changed). dashboard_idle_tick's near-zero cost is the numeric half of the "idle frames do zero dashboard work" guarantee (paramount #1); the correctness half — that it's not just fast but literally does no recompose — is pinned directly as a regression test (dashboard_idle_ticks_do_not_recompose in crates/lattice-host/tests/dashboard.rs), which asserts the dashboard document's version does not advance across idle ticks. A bench alone can show "fast"; it can't prove "zero work happened," which is why the test exists alongside it — the same "enforced, not asserted" bar the B2.3 bug story (below) argues for.

B2.3 — synchronous edit-path display rebuild (2026-06-04)

Display-line migration, slice B2.3 (design: ../architecture/display-line.md; slice plan: slice-plans/archive/display-line.md). The actor now rebuilds the edited region's canonical DisplayMatrix synchronously in the publish tail (sync_rebuild_pane_on_edit) BEFORE replying to the UI thread, so version.text never lags the snapshot — that lag was the per-keystroke whole-viewport stale-guard flicker. This latency sits directly on the keystroke→glyph ceiling (§8.2: one frame ≤ 8.3 ms at 120 Hz), so it has a hard bound.

New bench display_edit_path — times sync_rebuild_pane_on_edit for an in-place single-line edit at the middle of the document, across whole-doc (100) and windowed-chunked (5k, 100k) sizes. The sync path does ONLY the windowed incremental rebuild with highlight forced off (prefix/suffix Arc-reuse + edited-line text rebuild); no highlight_lines, no reparse, no cell projection (that stays on the async worker).

line_countdisplay_edit_path (median)
100~7.9 µs (whole-doc)
5000~4.4 µs (windowed)
100000~4.3 µs (windowed)

Flat at ~4 µs in chunked mode regardless of file size — O(window), not O(file) — and ~45× under the 200 µs target. (Whole-doc 100-line is slightly higher because it rebuilds the small file's full suffix via with_source_line; still trivial.)

Bug this bench caught. The first run measured 2.5 ms at 5k and 57 ms at 100k — O(file). The incremental rebuild's rebuild_hi defaulted to new_line_count when no suffix chunk remained (edit in the last covered chunk of a windowed matrix), so it materialised every row to EOF even though the full build is windowed (H.3). Editing near the top of a 100k-line file would have frozen the editor for 57 ms per keystroke. Fixed by bounding rebuild_hi to the published window's covered_end_line() shifted by net (try_incremental_display_build + its cell-path oracle). This is exactly the "enforced, not asserted" guarantee the slice plan mandated the bench for.

Note: the pre-existing cells_worker_incremental_build / _highlighted benches were also updated this slice to seed the display baseline (not just the cell baseline) so they exercise the real incremental path post-B2.2; the 1000-line figure (~213 µs) reflects its full-coverage cell projection (O(covered), ≤ window-cap), which runs on the async worker — off the edit-critical thread — and is deleted with the cell path in B4.

H.1 — range-scoped rebuild highlight (2026-06-04)

Incremental highlight initiative, slice H.1 (design: ../architecture/incremental-highlight.md). The cells worker now highlights only the line range a rebuild touches (highlight_lines(edit_lo, affected_hi)) instead of the whole file every keystroke.

New bench cells_worker_incremental_highlighted — incremental rebuild with a live syntax handle so the per-keystroke highlight cost is measured (the pre-existing cells_worker_incremental_build passes syntax: None and measures cell-build only; the delta between the two at the same line count isolates the highlight cost).

line_countincremental_highlighted (median)
100~19.7 µs
1000~1.08 ms
5000~1.28 ms

Caveat (read before trusting the absolute numbers): each iter clones the whole baseline matrix ((**baseline).clone(), O(file)) as setup — the same artifact cells_worker_incremental_build has — so the 1000/5000 figures are dominated by that clone, not the highlight. The H.1 win (scoped highlight) is the flatness of the delta vs the None-syntax bench, and is pinned by the correctness test h1_scoped_highlight_colours_the_edited_line. A regression to whole-file highlight would show this bench's delta-over-clone scaling with file size. H.3 (viewport-scoped) will add the headline large-file (100k) number with a clone-free harness.

MARG.4 — annotation pipeline benches (2026-06-03)

Bench file: crates/lattice-completion/benches/annotation_pipeline.rs. Run: cargo bench -p lattice-completion --bench annotation_pipeline.

Measures the typed-annotation pipeline (MARG.1) + the keybinding annotator's reverse-cache lookup (MARG.2). The cmdline popup runs this on every keystroke that re-filters the candidate list; budget headroom matters per paramount-#1.

Pipeline throughput

--quick (criterion 100-sample mode), dev box.

Bench / shapeMedian timePer-candidateNotes
annotate_pipeline_1000_3stage~167 µs~167 nsFull pipeline (kind + doc + keybinding) on 1000 candidates. ~half resolve to a bound chord, rest hit the empty-vec branch.
keybinding_annotator_1000~59 µs~59 nsKeybinding annotator only. HashMap probe + Vec clone on the bound subset.

Envelope for future regressions: the full 3-stage pipeline on 1000 candidates uses ~167 µs on the dev box. Applying the hardware-caveat 5× headroom for typical ultrabook hardware, the user-facing cost is ~835 µs — ~10% of the one-frame keystroke-to-glyph ceiling (8.3 ms at 120 Hz). That leaves ~7.5 ms for the rest of the frame (cell-grid build, text shaping, GPU draw), which is the desired profile: annotators are a minor cost, not a frame-eater. Any future change that pushes the 1000-candidate case past ~300 µs on the dev box (or ~1.5 ms after 5× scaling) should be reviewed against the §8.2 commitment.

The realistic-popup case (~50 visible candidates) costs ~50 × 167 ns ≈ 8.4 µs — well under any per-keystroke budget regardless of hardware.

Display-text format cost

Per-variant Annotation::display_text() cost. Renderer calls this once per visible row × annotation; cheap by design (Cow::Borrowed for string variants, structured format only for Keybinding).

VariantMedian time
kind~1.5 ns
doc~1.5 ns
keybinding (2 chords)~28.7 ns
source~1.7 ns
custom~1.6 ns

The keybinding variant is ~20× slower than the string variants because it allocates a String via fmt::Write to join the chord display forms. At 50 visible rows × 2 keybindings (max) = 100 calls × 28.7 ns = ~2.9 µs per paint — negligible. The string variants are indistinguishable from each other; Cow::Borrowed is basically free.

Regression envelope for display_text: any variant crossing 100 ns (or Keybinding crossing 200 ns) suggests an unintended allocation has crept in — the contract is "borrow when possible, format only when structured."

MR §8 — styled (per-segment) marginalia (2026-06-30)

The file/dir picker emits Annotation::Styled cells (a 10-segment permission string + size + mtime). Two new costs, both confirming the §8.7 "O(visible × segments), no measurable cost" claim:

Bench / shapeMedian timeNotes
styled_marginalia_columns_1000~142 µsAnnotationColumns::from_visible over 1000 file rows, each with a perm (10-seg) + size + mtime cell. Worst case — a real picker page is ~30 visible (≈4 µs).
annotation_display_text/styled_perm_10seg~53 nsMulti-segment display_text() concat for a 10-segment perm cell (the owning case alongside Keybinding).

The realistic page (~30 rows) costs ~4 µs to lay out — the same negligible profile as the command-completion annotators. The 10-segment display_text() concat at ~53 ns is ~2× the keybinding variant (it joins 10 single-char Arc<str>s into a String); at 30 rows × 1 perm cell that's ~1.6 µs per paint. Renderer-side per-segment slot resolution (BuiltinElementIds::annotation_slot — a match over slot keys, no allocation, no HashMap) is O(1) per segment and not separately benched; its cost is dominated by the display_text concat measured here.

PH — picker preview syntax-highlight resolution (2026-07-01)

Bench: cargo bench -p lattice-syntax --bench highlight -- picker_preview. The PH feature colors the code text inside picker rows (:picker lines / :picker outline) with the buffer's syntax colors. Spans are produced off the render thread at candidate-build time (build_picker_context → SyntaxSnapshot::highlight_lines); the render-seam cost is one resolve_syntax_style lookup per display char (picker-preview-highlight.md §3, O(visible chars)). This bench isolates that lookup.

Bench / shapeMedian timeNotes
picker_preview/resolve_viewport_4000_chars~6.8 µs4000 resolve_syntax_style calls (50 rows × 80 cols, cycling 8 common code styles) — ~1.7 ns/char.

A full picker page worth of syntax resolution (~4000 chars) costs ~6.8 µs — deep inside a single 120 Hz frame (8.3 ms), and the production of the spans (the tree query) never runs on the render thread at all. Confirms the §3 O(visible chars) claim: the per-char resolution is a match + theme-table index, no allocation, no parsing.

PI — picker preview isolation, per-selection-move cost (2026-07-07)

Bench: cargo bench -p lattice-host --bench preview. Design: docs/dev/architecture/preview-isolation.md. Measures a picker preview selection move under the isolated-projection model (PI.3) against the pre-PI activate-swap baseline. Debug-ish criterion build, already-open buffer B (no syntax parse in the loop).

Bench / shapeMedian timeNotes
preview_reseat_same_buffer~31 nsThe gr / grep hot case (several hits in one file): moving the selection just re-seats the pane override — no mode work, no option recompute.
preview_enter_exit~16.9 µsMount a preview of a different buffer, then unmount. Dominated by the preview-mode minor's activate/deactivate cascade (recompute_options_for_buffer + recompute_active_completion_sources_for + drain_option_changes), run twice.
activate_swap_baseline~1.3 µsPre-PI cost: activate_buffer(B) then activate_buffer(A), warm (the bench re-activates the same two buffers, so major-mode + option_cache are already built — an under-estimate of a cold cross-buffer activate).

Honest read. The reseat path — the same-buffer gr/grep case, and every move that stays on one buffer — is ~40× cheaper than a raw activate (~31 ns), delivering the design's O(1) exit/move promise. The enter/exit path (a move to a different buffer, e.g. find-file candidate → candidate) is not cheaper than the warm activate baseline in this microbench: the preview-mode minor chosen in §10.2 (option (a)) pays the full mode-activation cascade on both mount and unmount, ~16.9 µs total. That is still ≈0.2 % of a 120 Hz frame and preview moves are user-paced (not held-key bursts), so it is comfortably within budget — but it corrects the design's original blanket "preview is cheaper than activate" claim: it holds decisively for same-buffer moves and is a modest regression for cross-buffer moves. If cross-buffer preview latency ever matters, the cascade is the lever: preview-mode does not need completion sources recomputed, and a lighter activate that skips recompute_active_completion_sources_for + the non-option cascade arms would close most of the gap (or option (b), a read-only render flag with no mode lifecycle, would eliminate it).

Regression envelope: styled_marginalia_columns_1000 past ~300 µs, or styled_perm_10seg past 150 ns, signals an unintended allocation in the column-layout or concat path — review against §8.7.

MP §9 — picker marginalia rollout (2026-06-30)

The non-file pickers (commands / buffers / grep / jumps / outline / lines / marks / registers / snippets) emit the same Annotation::Styled (and typed Kind/DocSnippet/Source) cells, in the location / status / latency / args / buffer-id / register families. Same layout path as §8, benched over the rollout families:

Bench / shapeMedian timeNotes
styled_picker_columns_1000~183 µsAnnotationColumns::from_visible over 1000 rows, each a location (5-seg) + status (2-seg) + latency (1-seg) cell. Worst case — a real picker page is ~30 visible (≈6 µs).

Confirms the §9.5 "same O(visible × segments), no measurable cost" claim: the realistic ~30-row page lays out in ~6 µs, the same negligible profile as the file-metadata and command-completion paths. Per-segment slot resolution goes through the identical annotation_slot O(1) match.

Regression envelope: styled_picker_columns_1000 past ~360 µs signals an unintended allocation in the column-layout path — review against §9.5.

What this bench does NOT cover

  • Reverse-cache build cost at trie-rebuild time (KeymapRegistry::rebuild_reverse_cache per MARG.2). Trie rebuild happens at startup + on every :map / :unmap; not on the per-keystroke path. Bench deferred unless trie-rebuild latency becomes a complaint.
  • Renderer paint cost with N styled spans per row vs. the previous joined-string single-span shape. The TUI (ratatui) and GPUI peers both have separate frame-budget benches that include picker / popup paint; this is where paint-side regressions would surface.

M.2.c — multibuffer motion + compose benches (2026-06-01)

Bench files: crates/lattice-multibuffer/benches/multibuffer_motion.rs

  • crates/lattice-multibuffer/benches/multibuffer_compose.rs. Run: cargo bench -p lattice-multibuffer.

Motion latency (excerpt-jump motions)

Pure-helper latency for the four motions registered in lattice-multibuffer::motions. The motion handlers wrap these with MultibufferRegistry::handle(buffer_id) (an RwLock::read

  • HashMap::get + Arc::clone, sub-µs); the benches measure the geometry walk that dominates at large excerpt counts.

--quick numbers (criterion 100-sample mode), x86-64 WSL2:

Bench / sizeMedian timeNotes
next_excerpt_start/50~80 nsCI-relevant size; well under per-keystroke budget
next_excerpt_start/500~870 ns
next_excerpt_start/5000~7.9 µsStress shape; still 1000× under the 8.3 ms one-frame ceiling
prev_excerpt_start/50~80 nsSymmetric
prev_excerpt_start/500~850 ns
prev_excerpt_start/5000~8.0 µs
next_file_boundary/50~120 nsBoundary-list walk is one extra pass; cost difference small at small N
next_file_boundary/500~1.5 µs
next_file_boundary/5000~12 µs
prev_file_boundary/50~120 nsSymmetric
prev_file_boundary/500~1.6 µs
prev_file_boundary/5000~12 µs

Envelope for future regressions: ]e / [e are O(N) over excerpt count — the prefix-sum walk dominates. ]E / [E include the boundary-list scan (one extra O(N) pass). At the M.2 architecture-§7 CI-gate size (50 excerpts), all four motions land sub-µs. At 5k excerpts (well past any realistic single-view size), the worst case is ~12 µs — still ~690× under the 8.3 ms one-frame ceiling at 120 Hz. Any future change that pushes the 50-excerpt case past ~5 µs should be reviewed.

View construction + translation rebuild

Bench file: multibuffer_compose.rs. Three groups:

  1. multibuffer_compose_50_excerpts — cold MultibufferDocumentHandle::new over 50 excerpts × 20 rows across 10 source documents. Measures the construction-time compose_snapshot + RowTranslation::build cost.
  2. multibuffer_translation_rebuild — recompose() over a pre-built view at 100 and 1000 excerpts (the architecture §7 stress shape uses 1k × 20 = 20k composed rows).
  3. multibuffer_append_excerpts — provider-streaming path (append_excerpts(batch)); measures one 10-excerpt batch onto a pre-existing 50 / 500 excerpt view.

CI gates per architecture §7: compose ≤ 200 µs / 50 excerpts, translation rebuild ≤ 2000 µs / 20k rows. The bench infrastructure is in place; first baseline numbers land on the next regression sweep.

M.-0 — Document hot-path baseline (2026-05-31)

Baseline numbers for lattice-core::Document (the inner struct owned by DocumentActor). Two bench groups (document_read_p99_us, document_edit_p99_us) measure the per-frame read cost and per-keystroke write cost.

Scope clarification (2026-05-31): when these benches were added we expected them to gate M.0 (Document-as-trait refactor). After an architectural review the M.0 design moved to a handle-layer trait (Path B per docs/dev/architecture/multibuffer-views.md §3.1) which does not touch the inner Document struct — so these benches are no longer the M.0 gate. They stay as general-purpose regression infrastructure: the inner Document remains performance-sensitive and any future change to it (e.g., post-v1 rope-storage swap, buffer-side optimisations) should clear the same envelope. The M.0 review uses dispatch_publish / snapshot-cache benches instead, where the handle-layer trait dispatch actually lives.

Bench file: crates/lattice-core/benches/document_hotpath.rs. Run: cargo bench -p lattice-core --bench document_hotpath. Numbers below are from --quick mode (criterion's fast estimate, 100 samples instead of 100k); future regression checks should use the full criterion mode.

Bench / sizeMedian timeNotes
document_read_p99_us::viewport_walk/103.2 µs10-line doc, viewport is clipped → ~10 line reads + selection / version
document_read_p99_us::viewport_walk/10006.8 µs50-line viewport walk over a 1k-line doc
document_read_p99_us::viewport_walk/1000007.1 µs50-line viewport over a 100k-line doc — Buffer::line() is O(log n); the bench is mostly per-line String allocation
document_edit_p99_us::insert_at_middle/10~2.2 µsInsert "x" at midpoint
document_edit_p99_us::insert_at_middle/1000~1.5 µsSmaller than /10 because lines per page bucket differ — rope insert is sub-linear
document_edit_p99_us::insert_at_middle/100000~76 µsRope split / rebalance dominated
document_edit_p99_us::delete_at_middle/102.2 µsSymmetric to insert
document_edit_p99_us::delete_at_middle/10001.5 µs
document_edit_p99_us::delete_at_middle/10000076 µs
document_edit_p99_us::set_selections_motion/104.1 nsReplace SelectionSet field + version bump (no alloc today)
document_edit_p99_us::set_selections_motion/10004.4 ns
document_edit_p99_us::set_selections_motion/1000004.2 nsSize-independent (no rope access)

Envelope for future regressions on this layer: any change to lattice-core::Document (the inner struct, not the handle) should keep these numbers within noise of the above. viewport_walk reads are dominated by Buffer::line() String allocation today (~6–7 µs for 50 lines) — an optimisation that switches callers to direct &Rope slicing should improve this. Edit benches scale with rope size and are dominated by ropey internals; the bench catches regressions there too.

Post-3c.unify (slice 7 + 8, 2026-05-21)

Snapshot after the slice-7 unification arc (7a-7g) closed: single source-registration contract (SourceRegistration), typed accept payload on every picker candidate (RawCandidate::accept_action), dual-registry lookup in open_picker, accept dispatch via DefaultAcceptHandler, preview unification (LSP-references-preview gap closed).

The architectural question this run answers: does the unified-shape plumbing (carrying Option<AcceptAction> on every candidate that flows through the pipeline) regress picker filter performance? Initial measurement before slice 8's boxing optimisation showed +97% on the empty-query 5k case (774µs → 1.52ms) because AcceptAction is an enum carrying PathBuf / String / Args variants — its inline size dominated RawCandidate, doubling per-candidate memcpy through the matcher/ranker passes.

Slice 8's fix: box the field (accept_action: Option<Box<AcceptAction>>). Option<Box> is 8 bytes regardless of variant size. Cmdline-completion and insert-completion candidates leave the field None → null pointer → free. Picker candidates pay one heap alloc per row at construction, recovered ~10× over by smaller per-candidate memcpy during refilter (fires per keystroke).

Headline deltas

BenchPre-arc baselinePost-7 (inline)Post-8 (boxed)Δ vs baseline
picker::refilter/n=5000,query=""774µs1.52ms (+97%)801µs+3.5%
picker::refilter/n=5000,query="f"1.50ms1.41ms (-6%)1.43ms-4.7%
picker::refilter/n=5000,query="file_"1.57ms1.65ms (+5%)1.61ms+2.5%
picker::refilter/n=500,query=""~60µs60µs60µsflat
picker::open_inline/5000~1.66ms1.68ms1.94ms+17%
picker::open_inline/500~132µs130µs127µs-4%
picker::mru_snapshot/5000~542µs521µs520µs-4%

Verdict: the refilter hot path (per-keystroke; the budget that matters for input latency) lands within noise of the pre-arc baseline. open_inline/5000 shows +17% — the heap alloc per candidate at seat time. Acceptable because picker-open fires once per :picker <name> invocation, not per keystroke; user-perceived latency is dominated by the subsequent refilters.

Cost analysis

Each RawCandidate carrying accept_action: Some(...) pays:

  • 1 heap alloc at construction (~50ns × 5000 candidates = ~250µs); the source of the open_inline/5000 +260µs delta.
  • 8 bytes inline (Box ptr) instead of ~64 bytes inline (largest variant: OpenLspLog with String + PathBuf). Recovers ~280KB of memcpy per refilter at 5k scale.

Net: refilter wins, open_inline pays. The trade is favourable because refilter fires per-keystroke and open_inline fires once per picker invocation.

If/when open_inline becomes a hot enough complaint, the candidate-vec construction can move to a bumpalo arena (free all allocs at picker-close instead of per-candidate). Out of scope for slice 8; queued as 3c.unify.arena-candidates if needed.


Post-3c.final.E.swap + B-extension (2026-05-21)

Snapshot taken after the Phase 5.8.AF.5 / 3c.final arc closed: the Editor now lives on its own dedicated thread (slice 3c.final.E.swap, compile-time-enforced via the cfg-gated App.editor: Editor → App.editor_actor: EditorActorHandle swap), and slices B.7–B.9 lifted six per-frame read_editor round-trips off the hot paint paths to wait-free Arc::clone reads against published RS sub-states (Messages, Modeline, Options, Modes, SyntaxRS.pane_highlights, BufferLocals).

The architectural question this run answers: does compile- time-enforced async (paramount goal #4) plus the 6 new RS sub- states come at a measurable cost? Predictions made beforehand: snapshot_publish_standalone +50-200ns (~1ns per Arc::new × 6); apply_edit_round_trip / dispatch_round_trip +5-20µs from the actor mailbox; frame paint flat-or-faster; read paths flat.

Headline deltas

BenchBaseline (2026-05-13)Today (2026-05-21)ΔPrediction
runtime::snapshot_load/load16ns15.92nsflat✅ flat
runtime::snapshot_load_cached/steady290ps285.69psflat✅ flat
runtime::snapshot_publish_standalone/1095ns101.43ns+6.7%✅ +50-200ns; came in at +6ns
runtime::snapshot_publish_standalone/1000(95ns)102.05ns+7.4%✅
runtime::snapshot_publish_standalone/50000(95ns)98.81ns+4.0%✅
runtime::status_segment_update56ns55.68nsflat✅ flat
runtime::apply_edit_round_trip/1077µs81.26µs+5.5% (~4µs)✅ +5-20µs band; low end
runtime::apply_edit_round_trip/1000(77µs)77.16µsflat✅
runtime::apply_edit_round_trip/50000(77µs)77.43µsflat✅
runtime::dispatch_round_trip/1079.69µs77.75µs-2.4%⚡ better than predicted
runtime::dispatch_round_trip/100090.50µs87.31µs-3.5%⚡ better
runtime::dispatch_round_trip/50000572µs557µs-2.6%⚡ better
highlight::rust_viewport/24_lines185.23µs186.88µsflat✅ flat (control)
highlight::rust_viewport/60_lines259.12µs261.17µsflat✅ flat (control)
highlight::rust_viewport/120_lines—376.53µsnew

Architectural read

  • Read paths unaffected. snapshot_load (15.92ns) and snapshot_load_cached (285.69ps) are flat within criterion noise. The actor swap added zero overhead to the wait-free ArcSwap::load semantics — exactly the property that made the swap correctness-preserving.
  • Publish cost +6.7% (+6ns absolute). Six new sub-states each contribute one Arc::new(SubState { ... }) per publish. At ~1ns per Arc allocation + small struct move, the predicted ~6ns delta matches observed. On a default Editor the BufferLocals::clone deep-walk (added by B.9) is a no-op because there are no entries; production sessions with 5-20 open buffers × ~3-10 locals add some — bench coverage for that fully-populated path is queued as bench_publish_populated.
  • Dispatch round-trip improved 2–4%. The mpsc-send + oneshot-recv mailbox roundtrip cost was expected to add 5–20µs vs the pre-swap synchronous direct dispatch. It doesn't — and is actually slightly faster, because the prior "publish RenderState after every App helper" pattern has been replaced by one tail-publish per mutate_editor closure. Net: fewer publishes per Action chain, even with the channel overhead added.
  • Apply-edit round-trip flat at typical sizes. The +5.5% blip at /10 (~4µs) sits inside the per-bench noise floor (WSL2 host drift, documented below) and disappears at /1000 and /50000. No code-attributable regression.
  • Highlight viewport flat. rust_viewport/{24,60}_lines match the 2026-05-13 baseline within ±2%. The highlight pipeline doesn't touch the RS-publish path; this row was included as a control to confirm the bench environment is comparable, and it is.

Frame paint — fixed via 3c.fixup.actor-block-on + 3c.extension.fold-rs

First attempt at the render bench panicked. Two real defects surfaced, both addressed in same-day follow-up slices:

Defect 1: actor-runtime block_on mismatch (fixed in slice 3c.fixup.actor-block-on, commit 2647011). The actor thread runs a current_thread tokio runtime; lattice_runtime:: block_on called tokio::task::block_in_place whenever Handle::try_current() returned Ok, but block_in_place requires MultiThread — it panicked on the actor's runtime. Production impact would have been release-build panics on file save, LSP completion-resolve, synthetic-buffer seed, etc. cargo test missed it because cfg(test) preserves direct App.editor: Editor ownership (no actor spawned). Fixed by adding a Handle::runtime_flavor() branch: MultiThread uses block_in_place as before; non-MultiThread escapes to a fresh OS thread via std::thread::scope so target.block_on runs outside any tokio context.

Defect 2: per-frame actor RPCs in paint paths (fixed in slice 3c.extension.fold-rs, commit dc30942). With the panic gone, the render bench showed +373× regression: frame_120_ lines/200 at 43.73ms vs the 90µs 2026-05-13 baseline. Each paint of a 120-line frame paid ~120 actor mailbox round-trips (~94µs each) for per-line gates: app.line_inside_closed_fold, app.fold_start_at, plus per-line LSP mode-enabled checks for diagnostics / semantic-tokens / document-highlights / inlay- hints / progress, plus the gutter's per-line app.relative_line_ numbers(). The B-extension lifted most per-frame reads but missed these.

Two changes landed fold-rs:

  1. FrameView caches the per-frame option + mode-gate reads at construction (one read each at frame entry, then per-line lookups read the cached bool). Per-line callers (compose_visible_lines_inner, render_gutter_for, severity_for_line, diagnostics_on_line) switched from &App to &FrameView.
  2. App-level accessors (App::foldenable, App::lsp_*_mode_ enabled_for) rewritten to read RS directly. foldenable reads ad().option_cache.foldenable; the LSP gates read app.modes().map.get(buffer).has_minor(mode_id) against the ModesRenderState published by slice B.11.

Post-fix numbers vs 2026-05-13 baseline:

BenchBaselineTodayΔ
render::frame_24_lines/20015µs21.3µs+42% (criterion noise)
render::frame_60_lines/20046µs60.0µs+30%
render::frame_120_lines/20090µs117.3µs+30%
render::refresh_highlights_cache_hit21ns99.8µsirreducible — one actor RPC per call; not per-frame in production

The ~30% spread on frame paint is the FrameView construction cost (six Arc::load_full + an Arc-clone of the syntax spans + typed-options lookups; all wait-free) plus criterion noise from the host-state drift documented below. Tight enough for §8.2 (the frame budget is 500-800µs depending on viewport size).

refresh_highlights_cache_hit remains at the actor-RPC floor (~100µs) — this bench measures app.refresh_highlights() directly, which is mutate_editor(|e| e.refresh_highlights()). The mailbox round-trip itself is the cost. In production this fires on edit / scroll / config change, not per frame; the per-frame paint reads the worker-published visible_spans cell wait-free.

Host (highlights_worker) — new in this run

BenchTodayNote
worker_cache_hit/2451.69nswait-free cache lookup; measures the worker's input-key compare.
worker_cache_hit/6051.50ns
worker_cache_hit/12050.46ns
worker_recompute_on_scroll/24197.30µsscroll-only recompute
worker_recompute_on_scroll/60263.23µs
worker_recompute_on_scroll/120392.90µs
worker_stale_snapshot_hold/243.12µsstale-snapshot HOLD path
worker_stale_snapshot_hold/604.27µs
worker_stale_snapshot_hold/1205.52µs

What this means for the §8.2 commitments

Every row in the §8.2 commitments table above remains within its v1 target after the architectural changes:

  • Snapshot load (< 20ns) — 16ns, unchanged.
  • Snapshot load cached (< 500ps) — 286ps, unchanged.
  • Snapshot publish (< 500ns) — 101ns, +6ns from B-extension sub-states; still 5× under target.
  • Apply-edit round-trip (< 100µs) — 77-81µs, unchanged.
  • Dispatch round-trip (< 100µs at typical sizes) — 77-87µs, unchanged or slightly improved.
  • Frame render TUI 80×24 (< 500µs) — UNMEASURED this run; will re-bench after the block_on defect is fixed. Highlight-side contribution is unchanged (rust_viewport/24_lines flat).

Bench methodology — what changed vs 2026-05-13

The 2026-05-13 run used cargo bench --workspace. This run followed the same toolchain (1.94.0 stable) and bench profile (opt-level = 3). The --workspace run was abandoned mid-way on 2026-05-21 because it didn't fit the planned assessment window; targeted crate-by-crate bench runs (cargo bench -p lattice-{runtime,syntax,host}) gave the same numbers in a fraction of the time. Crates not benched today — lattice-config, lattice-grammar, lattice-picker, lattice-core — exercise paths untouched by the architectural changes; their 2026-05-13 numbers carry forward unchanged.

Bench environment continues to drift

Several criterion-flagged rows sit inside the documented WSL2 noise floor:

  • highlight::python/200: -6.9% (improvement, not regression — criterion's hypothesis test phrases both directions as "change detected").
  • highlight::python/2000: -19.9% (improvement).
  • motion::word_backward/10: +3.6% (1.34µs absolute, sub-µs delta, noise).
  • reparse_incremental_single_char_change/2000: 1.46ms → 1.94ms (+33%). This bench isn't touched by the architectural arc; suspect host-state drift (same WSL2 + CPU governor story documented in the 2026-05-13 section). Worth a controlled re-probe but not architecturally significant.

Post-perf-plan (2026-05-25)

Snapshot after the GPUI perf plan (archived at ../archive/gpui-perf-plan.md) closed. Nineteen slices shipped between 2026-05-21 and 2026-05-25, plus E.2 formally dropped on bench-justified grounds. The plan attacked the two dominant UI-thread costs identified in profiling (ensure_us and highlights_us) and ended with Editor::publish_render_state itself made identity-preserving on the seven highest-allocation sub-states.

Slice arcs that landed:

  • A. / B.1 / D.1 / E.1* — worker pre-paints rows (VisibleRows) with inlays woven in (RowRun enum); both renderer peers consume the same pre-woven rows wait-free; Arc<[T]> publish types collapse HOLD-path clones to a single Arc bump.
  • A.2b. / B.2.** — overlay buckets. Worker pre-buckets the three static overlay layers (doc_highlight / all_matches / substitute) per row in source-byte space; both peers consume the same bucket. Eliminates the per-frame O(N_overlay × V_row) intersection walk that scaled to ~1 ms/frame on a 1000-match hlsearch corpus.
  • B.4.a / B.4.b — identity-preserving Arc publish. Versioned<T> newtype + PublishCache on Editor cache seven sub-state Arcs (panes, modes, buffer_locals, buffers, tabs, inner syntax.pane_highlights, inner lsp.progress) keyed on per-field version counters. Reuses prior Arc when input version is unchanged.
  • C (FoldIndex, O(log N) visual-row math), A.3 (ensure gating), A.1 (rope-line window), F (release profile tightening), A.4 (logging demotion behind profile-frames) closed earlier in the arc.
  • E.2 (element-tree reuse) dropped after the E.2.α investigation found no bench-justified work — notify cadence is input-driven, conditional overlay-block construction is already correct, and the four candidate sub-slices each had measurement-backed reasons not to ship.

The architectural question this run answers: does the identity- preserving publish cache actually pay off in a measurable, no-overhead way? Predicted: ~50 % savings on no-op publish (steady_state regime where all cached inputs are unchanged) with sub-µs net cost on a fully-invalidated publish (the cache machinery shouldn't add cost the rebuild it's avoiding doesn't already pay).

Headline deltas — new bench dispatch_publish

Reproduce: cargo bench -q -p lattice-host --bench dispatch_publish. Fixture: editor with a 3-pane tree, 20 LSP-attached buffers (active_modes + buffer_locals + buffer_uris populated), 4 tabs, 3 panes × 60 spans of pane_highlights, 6 in-flight $/progress items.

BenchTimeΔ vs unmemoised (pre-B.4 equivalent)
dispatch_publish/steady_state3.23 µs−52 % (cache hits everywhere)
dispatch_publish/mutated_modes3.68 µs−45 % (one cache miss, six hits)
dispatch_publish/mutated_all6.44 µs−4 % (5 misses + bench-loop mutations)
dispatch_publish/unmemoised6.72 µsbaseline (cache cleared each iter)

The unmemoised row clears the cache between iterations with no per-iter mutation work — the cleanest pre-B.4 stand-in. The mutated_all row also pays for 5 HashMap insert/remove ops per iteration to bump the versions; its small delta over unmemoised is that bench-loop overhead, not cache overhead. The cache machinery itself (Mutex<PublishCache>::lock + 7 version reads + 7 slot compares + 7 Arc clones on hits / closure call + Arc::new per miss) is zero net cost on a fully-invalidated publish.

Headline deltas — highlights_worker (post-B.2)

Worker benchPost-A.2b.2bPost-B.2Δ vs post-A.2b.2b
worker_cache_hit/{24,60,120}~49 ns~50 nsflat
worker_recompute_on_scroll/24185.4 µs199.9 µs+7.8 %
worker_recompute_on_scroll/60260.0 µs282.5 µs+8.7 %
worker_recompute_on_scroll/120374.7 µs415.7 µs+10.9 %
worker_stale_snapshot_hold/{24,60,120}~2.6-2.9 µs~2.9 µsflat-to-+11%

The +7–11 % on the recompute path is the new per-recompute static- overlay bucket build (bucket_static_overlays walk + snap.source() access + the third Arc<ArcSwap<...>> cell store). Architecturally correct — every µs added on the worker thread is a µs removed from the renderer's per-frame body. Worker fires once per text/scroll change; renderer fires every paint.

Headline deltas — editor_element_frame (post-B.2)

GPUI prepaint bench (viewport 120)Post-A.2b.2bPost-B.2Δ
editor_element_frame_pre_paint104.3 µs90.1 µs−14 %
editor_element_frame_with_inlays130.2 µs118.6 µs−9 %
editor_element_frame_with_overlays94.0 µs89.9 µs−4 %

Renderer-side helper-bench numbers improved or held flat. The production active-pane path no longer calls these helpers for the static overlay layers — it consumes the worker bucket directly — so this bench measures the inactive-pane / fallback path. Active-pane real-world cost dropped by the worker-bucket lookup amount that the bench doesn't capture (no headless TestAppContext on gpui 0.2.2).

Architectural read

  • Editor publish halved on the realistic fixture (steady_state 3.23 µs vs unmemoised 6.72 µs). Most keystrokes don't touch panes / modes / buffer_locals / buffers / tabs / pane_highlights / lsp.progress, so most publishes now reuse every cached Arc instead of rebuilding them.
  • Renderer side gains the ability to short-circuit per-frame work by Arc::ptr_eq on consecutive frames' sub-state Arcs. No call site does this yet; the seam is in place for future slices.
  • High-N hlsearch tail-risk eliminated. Pre-B.2, the active-pane overlay_quads_for_row walked every (hlsearch_match, visible_row) pair per frame — ~1 ms/frame at viewport 120 with 1000 matches. Post-B.2 the worker emits ≤ a few quads per row in source-byte space; the renderer's walk is O(quads in viewport), not O(N × V).
  • No CI-gateable measurement of the production active-pane path. gpui 0.2.2 doesn't expose a headless TestAppContext, so the paint phase itself remains profile-frames-only. The two bench surfaces that ARE CI-gated (editor_element_frame for prepaint, dispatch_publish for the publish path, highlights_worker for the worker recompute) cover the dominant cost surfaces.

What this means for the §8.2 commitments

  • Snapshot publish (runtime::snapshot_publish_standalone) stays at 95–101 ns — B.4 operates one layer up (Editor::publish_render_state builds the RenderState that gets stored into the snapshot cell). The §8.2 row is unchanged.
  • Frame render TUI 80×24 / 200×60 stays under target. Active-pane paint is faster than the §8.2 commitments table reflects because the worker pre-paints rows + buckets overlays; the bench harness can't exercise that path headlessly so the §8.2 table still reports the pre-A.2 frame numbers as the gating reference.
  • Editor dispatch publish is a new measurement surface, not a §8.2 row. It sits on the actor tail every time Editor mutates and would scale up roughly linearly with publishes-per-frame; B.4 caps that cost on no-op publishes to ~3 µs / publish.

Environment

  • Date: 2026-05-13 (post Phase 4.4 + 4.5 LSP slices — supervisor refactor, file watchers, dynamic capability registration, callHierarchy/typeHierarchy/codeLens/documentLink/documentColor pumps + caches; no perf-targeted commits in the window)
  • Host: WSL2 (Ubuntu) on x86_64
  • Toolchain: Rust 1.94.0 stable
  • Build profile: bench (opt-level = 3)

WSL2 adds ~5-15% overhead vs. native Linux on syscall-heavy paths. Numbers below are conservative; native CI runners should land better.


§8.2 commitments at a glance

§8.2 rowTarget (v1)TodayBenchStatus
Snapshot load (load_full)<20ns16nsruntime::snapshot_load✅ at floor for load_full semantics
Snapshot load (Cache::load, steady)<500ps290psruntime::snapshot_load_cached✅ ~55× faster than load_full; sub-nanosecond
Snapshot publish standalone<500ns95nsruntime::snapshot_publish_standalone✅ at the floor (~80ns)
Status segment update<100ns56nsruntime::status_segment_update✅ at the floor
Apply-edit round-trip<100µs77µsruntime::apply_edit_round_trip✅ scheduler-bound; sync fast-path is the next lever
Dispatch round-trip (small buffer)<100µs79–91µsruntime::dispatch_round_trip✅ same envelope as apply-edit
Frame render TUI 80×24 (highlight + compose)<500µs~199µs (184 + 15)highlight::rust_viewport + render::frame_24_lines✅ under target
Frame render TUI 200×60<800µs~307µs (261 + 46)highlight::rust_viewport + render::frame_60_lines✅ under target
Open 100MB log (rope construction)<100ms74msbuffer::open_large/100mb✅ under target
Search literal worst-case 200k<2ms749µssearch::no_match_with_wrap/200k✅ under target; ~14% above prior baseline -- see "Regressions" below
Tree-sitter incremental reparsescale-by-size293µs (1600 lines), 1.46ms (16k lines)highlight::reparse_incremental_single_char_change✅ landed (B.2); ~8–16× under full reparse. tree.edit is O(num_nodes) — floor scales with tree size. See Slice B.2 calibration below.
Highlight span cache hit (steady-state)<50ns21nsrender::refresh_highlights_cache_hit✅ at floor (B.3); ~8900× faster than the pre-B.3 path.
Reflex motion / operator<2msmostly under; d_whole/50000 ≈ 3ms on this host (bench environment drift, not code)motion::*, operator::*⚠️ host-env regression -- bisect attributes the 1.23ms→3ms drift to WSL2 host state, not lattice code. See "Bench environment drift" below.
LSP framing parse (Content-Length)<500ns68nslsp::framing::parse_header_block✅ Background-class
LSP encode didChange<2µs183nslsp::encode::did_change✅ per-keystroke debounced outgoing
LSP decode publishDiagnostics<10µs1.50µslsp::decode::publish_diagnostics✅ per-save inbound
LSP utf-16 column conversion (CJK line)<1µs21nslsp::position::utf16_cjk_line✅ never shows up in flame graphs

Bench environment drift — not a code regression (2026-05-13)

Comparing this run against the numbers captured on 2026-05-03, several rows look like regressions on paper. A targeted bisect on the headline candidate (operator::d_whole/50000) showed the "regression" is environmental, not code-attributable.

What the bisect showed

Commit (probed today, same hardware)operator::d_whole/50000 (median)
c94d734 (commit where the 1.23ms baseline was first written, 2026-05-02)2.84ms
b75c135 (first commit with .tool-versions pin)~3.03ms
0759cc6 (last commit before the Phase 4.4 work-window)~3.46ms
dbf30f3 (HEAD)~3.03ms

At the exact commit that recorded 1.23ms in May, today's hardware reports 2.84ms — a 2.3× delta with zero code change. The real code-attributable delta across c94d734 → HEAD is 2.84ms → 3.03ms (~7%), well inside criterion's confidence interval. The 6.66ms number captured in the morning's full run wasn't reproducible 30 minutes later (later runs land at ~3ms in both --quick and full criterion modes).

criterion itself wasn't bumped between c94d734 and HEAD; the operator-walk code wasn't restructured. Candidate root causes for the host-side drift:

  • WSL2 kernel update. The env now reports Linux 6.6.87.2-microsoft-standard-WSL2; the kernel at 2026-05-02 isn't recorded in the prior doc, but a Windows-host update in the intervening 11 days is the most plausible vector. Scheduler
    • vDSO timer paths in WSL2 have historically shifted by ~2× between MS kernel revs.
  • CPU governor / thermal state. The host is shared with a Windows GUI; a sustained-load bench like d_whole/50000 (~5GB/s rope walk) is particularly sensitive to whether the CPU is sitting at peak frequency or has been parked.
  • L3 / NUMA pressure. A 50k-line buffer's ropey + tree-sitter state may now spill out of L3 where it didn't before; the smaller-size variants (d_whole/10, d_whole/1000) scale linearly today (5.15µs → 20.59µs ≈ 4×, matching the line-count ratio) while d_whole/50000 shows the non-linear knee.

What changed in the doc

  • The §8.2 commitments row for "Reflex motion / operator" reads ⚠️ instead of ✅, but the qualifying note now points at the bench environment, not lattice code.
  • The Operators table row for d_whole/50000 shows today's ~3ms number, not the stale 1.23ms; the prior baseline is preserved in the table footer for historical comparison.
  • The "Improvement target" column on d_whole/50000 calls out what would be a real regression: any movement past ~6ms at the same host state, since 3ms is now this hardware's natural floor for that bench.

Real (modest) regressions worth watching

Even after stripping out the host-drift noise, four rows moved ~10–15% in the wrong direction across c94d734..HEAD:

  • search::no_match_with_wrap/200000: 659µs → 749µs (+14%). Still inside the 2ms budget; the regex window-walk cost grew slightly, possibly from fancy-regex minor-version churn or memmem- windowed scan layout. Not worth chasing yet — investigate alongside other search-path work.
  • search::forward_last_match/200000: 469µs → 516µs (+10%). Same cause; same posture.
  • render::frame_60_lines/200: 42µs → 46µs (+10%). At a 200-fn buffer the renderer composes 60 visible lines; ~4µs added per frame is within ratatui write-noise but worth a flame-graph if it grows further. Suspect cause: option-cache misses on the new lsp-* sub-modes (mode-cascade work landed during the window).
  • render::frame_120_lines/200: 78µs → 90µs (+16%). Same cause scaled.

These four are inside the WSL2 noise floor (~±15%) but trend together, so the cascade explanation feels right rather than random.

Methodology note (added this run)

The bench environment isn't pinned today. Concretely:

  • No CPU-governor lock on the WSL2 kernel (cpupower frequency-set -g performance would help, if cpupower is exposed).
  • No host-side quiescence guarantee (background load on Windows affects WSL2 latency).
  • Criterion default sample size (100) is sensitive to one-off thermal spikes on sustained-load benches; d_whole/50000 is the prototypical sufferer.

When a future bench run produces a real code-attributable regression, the test is whether the same commit reproduces it on the same host state — re-probe at HEAD~10 or so as a control.

Improvements worth noting

The same window picked up real wins (mostly from the picker single-pass seat refactor in 1b095ae):

  • picker::open_inline/5000: 2.80ms → 1.43ms (−49%)
  • picker::refilter/n=5000,query="": 1.50ms → 644µs (−57%)
  • picker::refilter/n=5000,query="f": 1.35ms → 1.12ms (−17%)
  • motion::word_forward/50000: 700µs → 523µs (−25%)
  • motion::first_non_blank/indented-50k: 268µs → 226µs (−16%)
  • tree_edit_single_char/2000: 4.0ms → 2.66ms (−34%) — tree- sitter version bump or query-cache locality improvement
  • reparse_incremental_single_char_change/2000: 1.77ms → 1.46ms (−18%)
  • folds::compute_syntax_rust/2000: 323ms → 286ms (−11%)
  • highlight::rust_viewport/60_lines: 289µs → 261µs (−10%)
  • highlight::rust_viewport/120_lines: 388µs → 359µs (−7%)
  • runtime::apply_edit_round_trip: 83µs → 77µs (−7%)
  • runtime::snapshot_publish_standalone: 101ns → 95ns (−6%)

Runtime / actor (crates/lattice-runtime/benches/actor.rs)

The load-bearing async primitives (../architecture/design.md §5.2.1, §5.6.8, §5.7).

Benchmark10 lines1k lines50k linesFloor / TargetImprovement target
Snapshot publish standalone~95ns~95ns~96ns~80ns / <500ns⏹️ at the practical floor (Arc::new + atomic). Constant across sizes -- buffer clone is O(1).
apply_edit round-trip (block_on)76.5µs78.4µs77.5µs~50µs / <100µs🔼 sync edit fast-path drops to ~5µs (../architecture/design.md §8.2 stretch).
Dispatch round-trip (motion)~79µs~91µs~575µs~50µs / <100µs (small)⏹️ scheduler-bound on small bufs; large-buf cost is the motion walk itself.
Snapshot publish via apply_edit77.6µs79.7µs76.7µssame as apply_edit(envelope, not standalone publish)
Snapshot load (load_full)~16ns----~16ns / <20ns⏹️ at the floor (atomic acquire + Arc bump).
Snapshot load (Cache::load, steady)~290ps----~280ps / <500ps⏹️ sub-nanosecond. Per-thread cached; ~55× faster than load_full. Renderer's per-frame read.
Snapshot post-publish read71.4ns17.2ns19.5ns--🔼 same path
Status segment update~56ns----~50ns / <100ns⏹️ at the floor (snapshot load + small format).

Round-trip is constant across buffer sizes -- mailbox + oneshot

  • Arc clone, not a buffer walk. The ~85µs publish-via-apply-edit is the end-to-end cost; the snapshot-construct + arc-swap-store is sub-microsecond, bundled inside the round-trip.

snapshot_load_cached at ~305ps is the renderer's hot path; SnapshotCache::load returns a borrowed reference to the cached Arc after a single Relaxed atomic compare against the underlying ArcSwap pointer. When the writer hasn't published since the last load (the common case mid-frame), the compiler inlines the compare to a register read. The fallback path (load_full) stays available for callers that need an owned Arc outside the cache's borrow lifetime.

The renderer migration to SnapshotCache is the §5.6.8 read-side floor. The actor-internal write path is separate (see "snapshot_publish_standalone" above).


Search (crates/lattice-core/benches/search.rs)

Now backed by fancy-regex. Patterns without backrefs/lookarounds route through the regex crate's RE2-style DFA + SIMD literal prefilter; backref patterns fall back to a bounded NFA. All literal-search variants under §8.2's <2ms Reflex budget; the 200k-line worst case beats the prior memmem-only path by 40-50%.

Search101k50k200kImprovement target
forward_first_match2.08µs1.16µs2.36µs2.24µs⏹️ near floor (regex setup dominates on tiny scans)
forward_last_match2.40µs2.33µs103µs516µs⏹️ near floor; ~10% above prior baseline (see Regressions)
no_match_with_wrap1.56µs3.06µs158µs749µs⏹️ near floor; ~14% above prior baseline (see Regressions)
backward2.52µs1.47µs15.2µs--⏹️ near floor
Regex feature50kImprovement target
alternation2.69µs⏹️ regex literal-set extraction handles (foo|bar|baz)
class_quantifier1.16ms⏹️ general DFA path; under Reflex budget
backref (pathological)176ms🔼 fancy-regex backtracking; bounded by 1M-iteration recursion limit. Add per-search timeout for safety.

Implementation notes.

  • 128KB scan window amortises fancy-regex's per-call setup (~5µs/call × ~800 chunks → ~5µs/call × ~100 windows on 13MB).
  • from_utf8_unchecked on the window: rope chunks are &str (valid UTF-8); the drain logic preserves codepoint alignment via round_down_utf8_boundary. The unsafe call is gated by a module- level #![allow(unsafe_code)] with the safety argument inline at the call site. Every other module remains unsafe_code = "deny".
  • MAX_MATCH_LEN = 8KB bridge: matches longer than this AND spanning a window boundary are missed. Generous for editor patterns; pathological matches that span >8KB are not a v1 concern.

🔼 backref pathological pattern (169ms on 50k). fancy-regex's default backtrack limit is 1M iterations; our (handler_\d+)\b.*\b\1 pattern hits ~150ms before terminating. For an editor we'd want a stricter per-search timeout (target: abort at 50ms, surface "search timed out" to the user) -- needs the cancellation token contract (../architecture/design.md §5.2.5) to land first. Today the search just runs to completion or hits the recursion cap.

Negative result: B-γ (rayon parallel scan) was tried + reverted. Adaptive sequential-prefix + parallel-tail regressed every 50k+ bench. memmem / fancy-regex scan rare-prefix patterns at L2 bandwidth, so a 13MB scan fits inside rayon's spawn overhead. Documented in find_forward_in_rope's docstring.

Search history (this session):

BenchOriginalAfter α (memmem)After β (chunk-walk)After regex+windowΔ vs original
forward_first_match/200k1.0ms908µs211ns2.23µs-99.78% (450×)
forward_last_match/200k21ms1.29ms811µs469µs-97.8% (45×)
no_match_with_wrap/200k34ms1.4ms1.21ms659µs-98% (51×)
forward_last_match/50k4.4ms163µs189µs103µs-97.7% (43×)
no_match_with_wrap/50k8.7ms177µs288µs156µs-98.2% (56×)

Notable: regex+window beats the literal-only memmem path on every 50k+ benchmark. fancy-regex's literal prefilter on a 128KB window runs faster than memmem's tighter window because the per-call setup cost amortises across more bytes. The "near-cursor" small-buffer case (forward_first_match/200k = 2.23µs) is slightly slower than the prior memmem-only number (211ns) but still trivially under budget.


Motions (crates/lattice-grammar/benches/motions.rs)

Reflex-class. All under the <2ms p99 §8.2 budget.

Motion10 lines1k lines50k linesImprovement target
word_forward279ns9.86µs523µs🔼 SIMD whitespace scan via memchr (potential 5-10× on big files); ~25% faster than the 700µs prior baseline -- likely an upstream ropey win
word_backward1.25µs1.94µs108µs⏹️ near floor
word_end1.13µs1.59µs103µs⏹️ near floor
first_non_blank (50k indented)----226µs🔼 memchr memchr on b' ' / b'\t'
word_forward count=50 in 100x buffer611ns----⏹️
find_char_forward (900-char wide line)279ns----🔼 memchr (potential 3-5×)

🔼 Three motions could use memchr for the same reason search did: word_forward, first_non_blank, find_char_forward all do linear character-class scans. Replacing with memchr::memchr prefilter would give 5-10× wins on large files. Not a v1 priority (absolute numbers already pass §8.2).


Operators (crates/lattice-grammar/benches/operators.rs)

Reflex-class. All under the <2ms p99 §8.2 budget.

Operator10 lines1k lines50k linesImprovement target
dw (delete word)4.97µs17.3µs670µs⏹️
dd (delete line)5.43µs15.9µs840µs⏹️
d_whole (delete entire buffer)5.15µs20.6µs~3.0ms (one-off 6.66ms spike in this run's first execution; reproducible value ~3ms)⚠️ host-environment drift -- the historical 1.23ms is unreproducible at the same commit (bisect probed c94d734 today: 2.84ms). 50k case is the only operator past the 2ms Reflex budget; real regression threshold ~6ms going forward.
yw (yank word)6.16µs13.3µs890µs⏹️
cw (change word)4.93µs13.4µs687µs⏹️
diw (delete inner word)5.83µs3.84µs227µs⏹️
di_paren (deep arg list)8.79µs----⏹️

d_whole/50k at ~3ms is the only operator outside the 2ms Reflex budget on this hardware today. The historical doc value of 1.23ms (captured 2026-05-02 on the same WSL2 host) is no longer reproducible: a targeted bisect probed c94d734 (the commit that originally recorded 1.23ms) today and measured 2.84ms, with ±0.5ms noise across b75c135..HEAD. The delta is attributed to WSL2 host-state drift, not lattice code — see "Bench environment drift" above for the full bisect data + the candidate root causes (kernel rev, CPU governor, L3 pressure).

This means the row stays ⚠️ because the absolute number IS over the 2ms budget — but a code fix isn't the right lever; pinning the bench host's CPU governor + a quiet-host policy are. The other operators stay near floor; their cost is dominated by ropey's remove(...) work and is sub-millisecond at 50k lines.

The first invocation of the bench today produced a one-off spike to 6.66ms (recorded in the morning's full-suite output); every subsequent rerun (full and --quick) landed at ~3ms. The 6.66ms is treated as an outlier rather than the canonical number; a real P1 regression would need to reproduce stably across runs.


Folds (crates/lattice-ui-tui/benches/folds.rs)

Three computed fold providers; each measured across small / medium / large corpora so a regression on either small-file ergonomics or large-file scaling surfaces. Folds recompute on every reparse, so the budget is "stay sub-frame on realistic buffers."

ProvidersmallmediumlargeImprovement target
compute_indent1.9µs (10 fns)30µs (200)310µs (2000)⏹️ linear in line count; pure rust, no allocations beyond the result vec
compute_markdown1.0µs (10)6.7µs (100)30µs (500)⏹️ linear; ATX-heading scan + nesting walk
compute_syntax_rust64µs (10)3.7ms (200)286ms (2000)🔼 QueryCursor::matches traversal; sub-linear past 200 fns. Phase 5/9 incremental reparse + per-pattern caching is the lever.

The syntax provider's 200-fn time (3.7ms) is the relevant ceiling for real-world Rust files (typical ≤500 LOC). 2000-fn buffers are an outlier (~50kloc in one file). The bench pre-parses the source into a Syntax instance so the timing measures only fold-query work, not the underlying tree-sitter parse.

compute_syntax_rust covers function_item, struct_item, impl_item, if_expression, match_expression, block, etc. (see queries/rust/folds.scm). The query traversal cost grows with the number of pattern alternatives; pruning the captures we don't fold visibly (e.g. parameters, arguments for very-short ranges) is the next available optimization if 3.9ms ever pushes uncomfortable.

D.3.f.2 — fold-recompute integration + hunk overlay (crates/lattice-host/benches/fold_recompute.rs)

D.3.f.0 added a FoldProvider registry; D.3.f.1 attached the HunkFoldProvider overlay. The bench quantifies the marginal cost that those two slices put on the per-keystroke fold-recompute path so a future regression on either the registry indirection or the per-hunk emission shape surfaces immediately.

Workloadn=0n=10n=100n=1000Vs. 8.3 ms one-frame ceiling
overlay_only_at_n_hunks95 ns275 ns3.1 µs144 µs1000-hunk case: 57× headroom
hunk_provider_compute_pure1.9 ns138 ns1.3 µs12.4 µsprovider floor; integration adds ~15× through carry-over
fold_identity_hash————11.5 ns / hash (DefaultHasher, salted "diff:hunk")

overlay_only_at_n_hunks measures the end-to-end Editor::recompute_folds cost with foldmethod=Manual + a published HunkIndex. The Manual primary returns empty, so the registry dispatch + overlay emission + closed-state carry-over loop is what shows up. At n=100 (the upper end of realistic per-file hunk counts) recompute is 3.1 µs — over 2500× under the keystroke budget. At n=1000 (pathological: a thousand independent edits scattered through one file, an unlikely real shape) it grows to 144 µs which is still 55× under budget.

The gap between hunk_provider_compute_pure and overlay_only_at_n_hunks at the same n (e.g. 1.3 µs vs 3.1 µs at n=100) is the integration overhead: the carry-over loop walking the previous fold list to preserve closed-state, plus the post-merge sort. That overhead grows roughly linearly with N, consistent with the O(P + O + F) recompute described in fold-architecture.md §4.

CI gate enforcement deferred until the bench falls into a runner with stable wall-clock guarantees or a visual regression motivates an absolute ceiling. The headroom is large enough that catching a regression via routine bench-on-PR is sufficient.


Native highlight (crates/lattice-syntax/benches/highlight.rs)

Times Syntax::highlight_lines_native per language across the realistic call shapes the renderer actually issues.

BenchmarksizetimeFloor / TargetImprovement target
highlight::rust/1081 lines142µs~120µs / —⏹️ small-buffer setup floor (one full QueryCursor traversal).
highlight::rust/200~1600 lines2.93ms~3ms / <5ms🔼 per-pattern caching + pruning never-folded captures (~1ms achievable).
highlight::rust/2000~16k lines38ms--🔼 outlier (single-file 50kloc); the renderer never asks for full-buffer highlight.
highlight::rust_viewport/2424-line viewport184µs~150µs / <300µs⏹️ realistic frame call shape. The renderer's keystroke path lives here.
highlight::rust_viewport/6060-line viewport261µs~250µs / <500µs⏹️
highlight::rust_viewport/120120-line viewport359µs~350µs / <800µs⏹️
tree_edit_single_char/1080 lines4.6µs--⏹️ tree.edit() floor at small size (B.2).
tree_edit_single_char/2001600 lines167µs--⏹️ scales with tree node count, not constant (B.2).
tree_edit_single_char/200016k lines2.66ms--⏹️ −34% vs prior baseline (4.0ms) -- tree-sitter version bump.
reparse_incremental/1080 lines586µs--⏹️ slower than full at this size; tree-sitter incremental setup overhead.
reparse_incremental/2001600 lines293µs--⏹️ 8× faster than full reparse.
reparse_incremental/200016k lines1.46ms--⏹️ 16× faster than full reparse, fits 16ms@60Hz frame budget.
reparse_full_baseline/1080 lines199µs--⏹️ falsification anchor at small size.
reparse_full_baseline/2001600 lines2.47ms--⏹️ falsification anchor at medium size.
reparse_full_baseline/200016k lines23.7ms--⏹️ exceeds 16ms@60Hz budget -- why incremental matters at scale.

The viewport-bounded numbers are the meaningful ones. The full-buffer rows characterise worst-case query traversal cost but the renderer never asks for the whole document at once. At 24 lines (typical 80×24 terminal) we're at 178µs, well under the §8.2 frame-render budget.

tree_sitter::QueryCursor::set_byte_range is a hint -- the cursor still walks the entire tree to find captures that overlap the requested range. The ~178µs viewport floor reflects this; a true viewport-bounded query traversal (Helix's LanguageLayer incremental approach) is post-1.0 work.

Slice B.2 — Incremental reparse calibration

The incremental rows surface honest scaling: tree.edit() is O(num_nodes), not constant. Initial §8.2 estimate of "~500ns floor" was wrong; the real floor scales by tree size.

Speedup vs. full reparse:

  • 80 lines: incremental 0.4× slower -- tree-sitter's incremental setup overhead doesn't pay off below ~hundreds of lines. Both paths sub-ms; user-imperceptible.
  • 1600 lines: incremental ~8× faster (325µs vs 2.5ms).
  • 16k lines: incremental ~14× faster (1.77ms vs 25.5ms), AND incremental fits the 16ms@60Hz frame budget while full exceeds it.

Why we don't gate on file size. A threshold "use full reparse below N bytes" is tempting but adds a discontinuity that would be observable as latency-jitter when files cross the threshold during editing. The small-file regression is sub-ms in absolute terms; the architectural simplicity of "always incremental" is worth the 350µs at the small end.

Pathological-burst guard. The worker caps coalesced edits at 256 per request. A 100k-char paste-as-keystrokes would otherwise multiply the 4ms-per-edit (16k-line case) cost into seconds of pre-parse work. The cap drops the edit list and falls through to full reparse -- still produces a correct tree, just at full- reparse cost (which is what the user-paste path naturally hits).

C-series (C.1–C.5) — bench numbers now reflect production

The B.2 bench numbers were algorithmically correct from the day they landed but operationally dead until slice C.1. Pre-C.1, the syntax worker silently never spawned in production (tokio::runtime::Handle::try_current() failed because main was synchronous). Every request_reparse sent into a dropped channel; the snapshot stayed at the seeded state forever. The B.2 incremental numbers reflected what the worker would do if it ran -- they didn't reflect what users experienced. The C.1 #[tokio::main] migration plumbed the runtime in from program start; only then did production users actually see incremental reparse.

The C-series adds no new benches but introduces a sub-µs synchronous-shift cost on every edit:

  • shift_highlights_for_edit: O(N) Vec drain/insert where N is lines added or removed by the edit. Typically 0 (in-line) or 1 (line delete/insert). ~tens of ns.
  • shift_spans_within_line: O(span_count) on the edited line, with each span doing a single i64 add. Typical line has <20 spans. ~hundreds of ns.

Total C-series input-thread overhead per edit: <1µs. Doesn't show up in the existing bench rows because they don't exercise the edit→render cycle as a unit. The visible-side win (flicker elimination) is correctness-not-perf; the existing refresh_highlights_cache_hit/200 at 20ns is unchanged.


Frame render (crates/lattice-ui-tui/benches/render.rs)

Times compose_visible_lines against pre-warmed highlight cache -- the per-frame view-composition cost on top of the highlight work above.

BenchmarksizetimeFloor / TargetImprovement target
render::frame_24_lines/20080×24, 200 fns15µs~10µs / <50µs⏹️ near floor; +13% vs prior (~13µs) -- see Regressions.
render::frame_60_lines/200200×60, 200 fns46µs~30µs / <100µs⏹️ +10% vs prior (~42µs); option-cache miss on the new lsp-* sub-modes is the prime suspect.
render::frame_120_lines/200200×120, 200 fns90µs~70µs / <150µs⏹️ +16% vs prior (~78µs); same cause, scaled.
refresh_highlights_cache_hit/1080 lines21ns~10ns / <50ns⏹️ steady-state cache hit (B.3); independent of size.
refresh_highlights_cache_hit/2001600 lines21ns~10ns / <50ns⏹️ same -- key compare short-circuits before any work.
refresh_highlights_cache_hit/200016k lines21ns~10ns / <50ns⏹️ same; ~8500× faster than the pre-B.3 ~178µs path.

The frame_60 / frame_120 rows ticked down ~15% after the renderer migrated to Cache::load + a single per-frame snapshot (632310d). compose_visible_lines no longer pays an internal load_full per call, and closed_fold_display_span (called per fold heading) no longer pays one each either. frame_24 is unchanged within noise -- the savings scale with viewport height because more visible folds = more eliminated loads.

Combined with viewport-bounded highlight (178µs at 24 lines), the total per-frame cost on the editor side is ~192µs -- well under the §8.2 "Frame render TUI <500µs" target. The remaining cost is ratatui's terminal write, which isn't measured here (hardware- bound; not benchable without a real TTY).

Slice B.3 -- highlight span cache (refresh_highlights_cache_hit above): on the steady-state frame (cursor blinking, no edit / scroll / fold change), the 178µs highlight_lines call is now a 20ns key-compare + early return. The total editor-side per-frame cost on a steady-state frame drops from ~192µs to ~33µs (compose 13µs + cache check 20ns), a ~6× speedup -- the cache check component is 8900× faster than the path it replaces. Cache key is (snapshot_ptr, text_version, scroll, viewport_height, fold_hash); any actual change invalidates it and falls through to the original recompute path, which is unchanged.

Typed-options migration (75e2390 → 1bfee16): the initial landing of lattice-config regressed render frames by 25-57% because every per-frame option read (app.show_line_numbers(), app.foldmethod(), ...) went through the registry's mutex + ArcSwap + downcast (~33ns per read; benchmarked in config::get_bool_via_handle). At 60-120 visible lines × 2-4 reads per line, the path added multiple microseconds per frame. The follow-up (commit landing this entry) restores baseline by caching the option values on App.option_cache, refreshed via the Event::OptionChanged cascade in apply_option_cascade. Reads become field accesses (~1ns); the canonical value still lives in lattice-config::ConfigRegistry, the cache is a derived projection. Numbers above reflect the post-fix state.


Tree-sitter consolidation (Option B migration)

Architectural change, 2026-05-03. lattice-syntax previously ran tree_sitter_highlight::Highlighter (which parses internally) AND a separate tree_sitter::Parser for the folds query — two parses per edit on every recognised buffer. The Option B migration (Steps 1–4) collapsed both onto a single Parser + Tree owned by Syntax:

  • One parse per edit. Highlight, folds, and any future query consumer (textobjects.scm, indents.scm, locals.scm) all walk the same Tree.
  • tree-sitter-highlight dropped from the dep tree; the hand-rolled pipeline reads highlights.scm directly via QueryCursor with later-pattern-wins overlap resolution.
  • Markdown injections (block→inline + fenced code blocks) recurse one level: a \``rust ... ```` block inside markdown reuses the rust highlights query.

The user-visible win lands when the document/syntax actor (§5.7) is threaded through with Tree::edit deltas — the seam exists today, and incremental reparse will further reduce per-keystroke cost on large buffers. A dedicated highlight bench is on the "what's NOT here" list below.


Buffer ops (crates/lattice-core/benches/buffer.rs)

Direct rope mutations.

Operation10 lines1k lines100k linesImprovement target
insert_at_origin1.71µs1.14µs66.0µs⏹️ ropey is the floor
insert_at_middle1.96µs1.96µs66.4µs⏹️
delete_one_byte2.14µs1.53µs66.7µs⏹️
position_byte_round_trip863ns372ns323ns⏹️ B-tree is faster on bigger ropes
input_edit_construction1.82ns----⏹️ at §8.2's ~2ns floor (B.1)
clone_vs_text/clone7.7ns7.7ns7.8ns⏹️ Arc bump on ropey's internal Arc (B.5)
clone_vs_text/as_string79ns991ns211µsfalsification anchor; pre-B.5 path (full materialization)

input_edit_construction is the new tree-sitter-shaped delta construction at the tail of Buffer::apply_edit -- six u32 writes plus three Position copies. Backs §8.2's Write-path row "InputEdit construction (per Document::apply_edit)" -- floor ~2ns, target <10ns, today 1.87ns. The full apply_edit cost is dominated by the rope mutation above (insert_at_origin ≈ 2µs); delta construction is in the noise floor.

clone_vs_text measures the slice-B.5 input-thread cost reduction. Pre-B.5, Document::text() materialized the full buffer to a String on every keystroke (~189µs at 100k lines, on the input thread). Post-B.5, Buffer::clone() (Arc bump) replaces it (~7.7ns flat, independent of size). 24,500× faster at large sizes; the full-text alloc moves to the syntax worker per goal #1. The as_string row stays as the falsification anchor -- if it ever matches clone, ropey's internal sharing changed.

Open-large benchmarksizetimethroughputFloor / TargetImprovement target
buffer::open_large10MB3.5ms2.9 GiB/s~2ms / <10ms⏹️ near floor (memcpy-ish into ropey's internal buffer).
buffer::open_large100MB74ms1.3 GiB/s~50ms / <100ms⏹️ B-tree split cost compounds; under §8.2 first-paint target.

position_byte_round_trip is faster on bigger ropes -- ropey's B-tree packs better at scale.


Typed options (crates/lattice-config/benches/options.rs)

The §5.12 typed-options registry. Read paths (config.get / config.with) appear inside the renderer's per-line gutter checks; write paths (config.set / parse_and_set_command) are cmdline / plugin / customize-buffer triggered (cold). Hot-path reads are cached on App.option_cache (~1ns field access); these benches measure the underlying registry costs.

BenchTimeFloor / TargetNotes
config::get_bool_via_handle33ns~30ns / <50nsMutex acquire + Arc::clone + as_any().downcast_ref + ArcSwap::load_full + Arc<bool> deref.
config::with_int_via_handle26ns~25ns / <50nsSkips one Arc::clone vs get; the cheaper closure-style read.
config::lookup_by_name35ns~30ns / <100nsHashMap probe + Arc::clone. Cmdline path uses this; not on the per-frame render hot path.
config::set_no_publisher134ns~100ns / <500nsValidate + ArcSwap::store. No event publisher wired -- baseline cost of the typed write.
config::set_with_publisher144ns~120ns / <500nsSame as above plus the publisher closure -- registry's contribution to the §5.10 OptionChanged flow.
config::parse_and_set_command_bool217ns~180ns / <1µsFull cmdline path: parse_set + lookup + parse_and_set + format echo + publish.
config::resolved_get_typed13.9ns~12ns / <50nsM.2.1: type-keyed read against the per-buffer ResolvedOptions cache. One TypeId HashMap probe + Arc::clone + downcast. The hot-path read for mode-aware option access; the App.option_cache projection sits on top for sub-ns reads.
config::resolve_into_10_layers1.89µs~1.8µs / <10µsM.2.1: full recompute -- bootstrap from registry currents, then layer 10 minor-mode contributions on top. Per ../architecture/mode-architecture.md §6.3.2 the gate is p99 < 10µs at 10 minors; we hit ~5× headroom because the bootstrap walk dominates and the layer merge is bounded. +2.2× vs prior baseline (851ns) -- new sub-modes (lsp-progress / lsp-document-highlight / lsp-selection-range / lsp-folding / lsp-inlay-hint / lsp-semantic-tokens) registered through Phase 4.4 are layered into the bootstrap; cost is amortised across reads, still well inside the 10µs gate.

The App.option_cache projection turns the per-frame renderer reads into ~1ns field accesses; M.2.1's resolved cache (per buffer, mode-aware) sits between option_cache and the registry, used when the renderer needs mode-resolved values rather than the global default. Writes remain cold (cmdline / plugin / customize-buffer triggered); mode toggles are roughly an order of magnitude rarer than :set writes, so the recompute cost is amortised across many reads.


Picker (crates/lattice-picker/benches/picker.rs)

Per ../architecture/picker.md § 9.2: criterion benches for the picker primitive's hot paths. refilter is the per-keystroke filter+rank pass; open_inline is the seed-and-bonus-snapshot path; mru_snapshot is the host-side O(N) cache pass; mru_record is the accept-time index write; bonus_of is the frecency math kernel.

BenchTimeFloor / TargetNotes
picker::open_inline/10026.4 µs~25 µs / <500 µsBuild candidates + bonus snapshot + single-pass seat. Buffer-switcher scale.
picker::open_inline/500120.0 µs~120 µs / <1 msLSP-symbols / outline scale.
picker::open_inline/50001.43 ms~1.4 ms / <8.3 ms−49% vs prior baseline (2.80 ms) -- the single-pass seat collapsing the prior double-refilter (commit 1b095ae) lit up here. Worst-case file-picker walker output (5000 candidates).
picker::refilter/n=500,query=""52.3 µs~50 µs / <500 µsEmpty-query refilter on 500 candidates. No filtering, just rank+sort.
picker::refilter/n=500,query="f"99.3 µs~100 µs / <500 µsSingle-char substring filter -- the match-range walk dominates over the trivial empty-query bypass.
picker::refilter/n=500,query="file_"122.9 µs~120 µs / <500 µs5-char query against a substring-matching candidate set.
picker::refilter/n=5000,query=""644 µs~650 µs / <2 ms−57% vs prior baseline (1.50 ms) -- same single-pass seat collapse. Empty-query rank+sort dominates here.
picker::refilter/n=5000,query="f"1.12 ms~1.1 ms / <2 msSubstring filter rejects nothing (every path contains f); cost is matcher walk × N.
picker::refilter/n=5000,query="file_"1.35 ms~1.3 ms / <2 msWorst-case substring scan + full-match rank at 5000 candidates. Consumes ~16% of the 8.3ms frame budget at 120Hz; tightening levers in "Headroom" below.
picker::mru_snapshot/10011.0 µs~10 µs / <100 µsO(N) HashMap-lookup pass. Runs once per picker-open.
picker::mru_snapshot/50055.8 µs~55 µs / <500 µs
picker::mru_snapshot/5000513.6 µs~500 µs / <2 msAt 5000 candidates the snapshot cost is dominated by HashMap probes; cap-per-namespace bounds the cost in practice (most entries lookup-miss to 0.0).
picker::mru_record/100915 ns~1 µs / <10 µsSingle accept: HashMap insert + cap-check. Steady-state cost; the user feels none of it.
picker::mru_record/100061.7 µs~60 µs / <500 µsAt-cap insert: the eviction path runs through lowest_frecency_in_namespace (linear scan + frecency compute per entry). Rare in practice (only fires when a namespace hits its 1000-entry ceiling).
picker::bonus_of21.8 ns~20 ns / <100 nsFrecency formula kernel. Called once per candidate during snapshot; this floor is what sets the snapshot's per-entry cost.

Measured on the workstation listed in § Environment, release profile, criterion default sample size = 100. Numbers are mean point estimates; criterion ranges (lower / upper) are within ±5 % at this sample size.

Why these targets

  • refilter sub-frame. Per CLAUDE.md paramount goal #1 (sub-frame keystroke-to-glyph), the refilter pass runs per-keystroke. At 60Hz the frame budget is 16 ms; at 120Hz it's 8.3 ms. The 5000-candidate worst case at ~1.5 ms leaves ample headroom on both, and the typical case (100-500 candidates) is well under 200µs.
  • open_inline headroom. Picker open is user-invoked (:picker <source>), not per-keystroke. The 5000-entry case at ~3.1 ms is below user-perception threshold (~100 ms for "instant"); the double-refilter inefficiency is the only thing keeping it above 2 ms.
  • mru_record per-accept. A user can't accept faster than ~5/sec by typing <CR> repeatedly. Even the at-cap eviction path at 64 µs is invisible. The steady-state ~1 µs cost is below noise.
  • bonus_of per-candidate. The frecency math runs once per candidate during snapshot. At 22 ns × 5000 = 110 µs -- reasonable correspondence with the 530 µs snapshot bench (the difference is HashMap-probe overhead). If bonus_of ever regresses past 100 ns the snapshot bench will catch it before the user notices.

Headroom notes

The refilter/n=5000 worst case is at ~1.5 ms today -- inside the sub-frame budget but consumes ~18 % of the 8.3 ms frame budget alone. Two tightening levers if this ever needs more headroom:

  • Matcher graduation. The v1 substring matcher walks the full display string per candidate. The pipeline- driven matcher (lattice-completion full vertico stack) short-circuits prefix / boundary tiers and would cut the 5000-candidate cost roughly in half.
  • Survivor-set caching. Today every keystroke calls refilter against the full raw slice. A two-stage pipeline (cache the survivor set of the previous query; incremental filter only when the user adds a char) is the standard prescient trick; lets a 5-char query refilter against ~50 candidates instead of 5000.

Neither lever is needed at v1's typical workloads (<500 candidates) where refilter is well under 200 µs.

The previously-listed open_inline double-refilter cost (prior revision of this section) was collapsed by the set_raw_candidates_with_routing_and_bonuses single-pass seat method -- numbers above reflect the post-collapse state.


LSP wire layer (crates/lattice-lsp/benches/lsp.rs)

Per ../architecture/design.md §5.4 + §5.2.5, LSP requests are Background-class (no sync-prelude budget). The wire-layer benches don't gate any per-keystroke commitment; they exist to prove the plumbing itself never appears next to editor work in a flame graph.

BenchTimeFloor / TargetNotes
lsp::framing::parse_header_block68ns~50ns / <500nsOne ASCII header block, ≤200 bytes. Runs once per inbound message.
lsp::encode::did_change183ns~150ns / <2µsOne TextDocumentContentChangeEvent with a small replacement. Runs once per debounced keystroke.
lsp::decode::publish_diagnostics1.50µs~1µs / <10µsDiagnostic with code + range + source + message + severity. Inbound on save / idle.
lsp::decode::small_response383ns~250ns / <2µsinitialize / hover response shape.
lsp::encode_decode::hover_request878ns~600ns / <5µsEncode + decode round-trip (no I/O) for a typical request.
lsp::position::utf8_passthrough1.0ns~1ns / <5nsutf-8 negotiated mode short-circuits to a branch + return.
lsp::position::utf16_cjk_line21ns~20ns / <500nsWorst case: 64-char CJK-only line, mid-line offset. Walks prefix counting utf-16 code units.
lsp::position::utf16_to_byte_cjk41ns~30ns / <500nsReverse direction: utf-16 column → utf-8 byte. Used for ranges arriving FROM the server.
lsp::logging::log_info116ns~80ns / <500nsPer-record cost: lock + push + format + tracing fan-out. Background-class.
lsp::logging::log_trace_off10ns~5ns / <50nsTrace toggle off short-circuit -- a HashSet lookup + return. Hot path when trace stays disabled.
lsp::logging::log_trace_on116ns~80ns / <500nsTrace toggle on -- includes the ring push. Negligible at editor pace; perceptible at indexer bursts.
lsp_edit_publish_three_subs2.4µs~2µs / <5µsUI-thread cost per applied edit: EventBus::publish of one Event::DocumentChanged with one AppliedEdit, three DocumentChanged subscribers attached. The only LSP work the keystroke thread does after the per-actor fan-in refactor (docs/../architecture/lsp-architecture.md §11). +25% vs prior baseline (1.9µs) -- the new MessagePushed + LspCodeLensRefresh typed-event subscribers landed in Phase 4.4/4.5 add small downcast costs per publish; still ≪ 5µs gate.
lsp_edit_propagation_publish_to_recv241ns~200ns / <600nsBus → mpsc receive hop: time from EventBus::publish to the per-actor fan-in's mpsc::recv().await returning. Excludes the actor's own record_edit.
lsp_didchange_flush_16_edits8.4µs~6µs / <25µsActor-side debounce-arm cost: 16 DocSync::record_edit calls + take_flush_payload + serialise to textDocument/didChange JSON. Runs off the UI thread (post-debounce).
lsp_diagnostics_line_severity_wait_free25ns~20ns / <75nsRender-thread DiagnosticsLayer::line_severity(uri, line) after the audit's C3 fix. Pre-fix path locked an inner Mutex + cloned the full diagnostics list per call (microseconds, ~3000 calls/sec on the render thread = milliseconds wasted). New path: one ArcSwap::load + a borrowed-slice filter — wait-free, allocation-free.

The full LSP feature matrix (per-method status) lives in ../notes/lsp-features.md; the architecture in ../architecture/lsp-architecture.md. Per-feature benches (request round-trip latency end-to-end through a real server) land alongside their features in 4.2 / 4.3 / 4.4.

Why these targets

The per-actor fan-in refactor moved DocSync into the actor and removed the supervisor mutex from the keystroke path. The three rows above split the resulting cost into the three moments that matter:

  • UI thread (lsp_edit_publish_three_subs): publishing one event must stay deep in microseconds even with several attached actors. The §8.2 keystroke-to-glyph ceiling is one frame, 8.3 ms; budgeting <50 µs for "tell the LSP layer" leaves >99% of the frame for the rest of the path. 1.9 µs ≪ 50 µs.
  • Propagation (lsp_edit_propagation_publish_to_recv): bus → fan-in receive hop. Sub-microsecond means the actor sees the event in the same tick the publish completes; diagnostics, hover, completion never lag a keystroke behind. 227 ns ≪ 5 µs.
  • Async flush (lsp_didchange_flush_16_edits): cost paid by the actor task on its debounce arm. Off the UI thread; bound by JSON encode + a string splice per edit. 8.4 µs for 16 edits = ~525 ns/edit.

Regressions in any of these rows would mean the refactor's core promise (UI thread untouched by LSP work) is leaking.


Keymap (crates/lattice-ui-tui/benches/keymap.rs)

The audit's M3 / Slice 8 family rebuilds key-input dispatch as a typed KeyChord → trie-driven lookup. Slice 8.a lands the foundation -- KeyChord type + KeyEvent ↔ KeyChord ↔ String round-trip. The keystroke path runs KeyEvent → KeyChord once per key press; KeyChord → String and String → KeyChord run off the keystroke path (:describe-key, macro recording, startup catalog enumeration).

BenchTimeFloor / TargetNotes
keychord_from_event_plain_letter1.6ns~1.5ns / <5nsHot path: every keystroke. Plain printable char (j, w, a). A few register operations -- the canonicalisation branches all skip. Dominates the keystroke-path budget by 50×.
keychord_from_event_ctrl_letter1.8ns~1.5ns / <5nsHot path: Ctrl-letter normalisation (lowercase fold + redundant-shift strip). Adds one branch + one to_ascii_lowercase over the plain-letter path.
keychord_from_event_back_tab1.4ns~1.5ns / <5nsSpecial-key canonicalisation (KeyCode::BackTab → Tab + KeyMods::SHIFT). Match arm + a single bitfield OR.
keychord_to_string_plain_letter15.8ns~15ns / <40nsOff the keystroke path. Allocates a 1-char String via to_string. Dominated by the alloc, not the formatting logic.
keychord_to_string_ctrl_shift_letter22.1ns~20ns / <50nsOff the keystroke path. Multi-modifier formatting + small-string allocation.
keychord_parse_plain_letter5.1ns~5ns / <15nsOne-shot at startup or :bind. Single-char fast path -- skip the angle-bracket walk.
keychord_parse_modifier_special13.6ns~12ns / <30nsOne-shot. <C-S-Tab> -- walks two modifier prefixes + parse_special for the body.
parse_chord_sequence_multi_key24.8ns~20ns / <60nsOne-shot at startup per KeymapEntry. With ~280 built-in bindings (per the M3 census), startup parse cost across the catalog is ~7µs total -- not measurable against the rest of boot.
parse_chord_sequence_two_letters14.9ns~12ns / <30nsOne-shot. gg / dw / zt shape -- two bare-char chords per sequence.
keymap_trie_lookup_single16.3ns~15ns / <40nsHot path. Single-chord lookup (j). One HashMap::get + a few branches. Slice 8.b.
keymap_trie_lookup_two_chord27.1ns~25ns / <60nsHot path. Two-chord lookup (gd). Two descents. Models g_ and z_ family lookups.
keymap_trie_lookup_three_chord40.5ns~40ns / <100nsHot path. Three-chord lookup (diw). Three descents. Operator + i / a + text-object -- the deepest trie walks the dispatcher does. Combined with keychord_from_event (~2 ns), end-to-end keystroke path is ~43 ns vs. the architecture's 1 µs commitment.
keymap_trie_lookup_partial11.8ns~12ns / <30nsHot path. Partial-prefix lookup (g waiting for the second chord). One descent + check.
keymap_trie_lookup_unbound11.2ns~10ns / <30nsHot path. Unbound lookup (q not in trie). HashMap miss at root + return.
keymap_trie_lookup_wildcard23.5ns~22ns / <60nsHot path. Wildcard fallback (f x -> capture 'x'). One exact miss + one wildcard descent + a one-element Vec<char> allocation for the captured char.
keymap_trie_merge_overlay418ns~400ns / <1µsOff the hot path. merge_over for a layer-overlay add (~16 base bindings + 2 overlays). Runs at minor-mode push / pop -- mode transitions are rare.
keymap_register_production_catalog/with_motion_mirror1.48ms~1.5ms / <5msStartup, once (VM.4). The whole production registration burst: every register_*_bindings, expand_grammar_rows, and the derived-state rebuild one lookup forces, with the Visual / Select motion mirror armed as boot arms it. Nothing measured this burst before; bind_bound's comment records it once cost 734.8 ms when every bind rebuilt derived state.
keymap_register_production_catalog/without_motion_mirror1.27ms—Same burst with no command registry, so no mirror. Not a like-for-like catalog (Visual lacks its motion rows); it exists so the mirror's cost is visible on its own: +0.21 ms (~16%), paid once per keymap build, never per keystroke.
keymap_handle_lookup_single32.4ns~30ns / <80nsHot path. End-to-end keystroke lookup through the registry handle: ArcSwap::load + per-mode HashMap::get + trie walk. Single-chord (j). Slice 8.c.
keymap_handle_lookup_two_chord45.1ns~45ns / <100nsHot path. End-to-end two-chord lookup (gd).
keymap_handle_lookup_three_chord61.1ns~55ns / <120nsHot path. End-to-end three-chord lookup (diw). Combined with keychord_from_event (~2 ns), full keystroke path is ~63 ns vs. the architecture's 1 µs commitment -- ~16× headroom.
dispatch_translate_full_two_chord97ns~100ns / <300nsHot path. Full translate() round-trip for the second key of gd -- partial_chord stack of [g], event d. Exercises the post-8.i.4 dispatch shape: ArcSwap load + per-mode fan-out + trie lookup with prefix + resolved Action::Invoke materialisation. ~3× the bare keymap_handle_lookup_two_chord row -- the rest is the dispatcher's mode match + Action construction. Slice 8.i.4.h.
dispatch_translate_full_operator_motion101ns~100ns / <300nsHot path. Full translate() for dw -- partial_chord [d], event w. Operator-motion variant of the above; latches op_count via the AbsorbOperatorPrefix flow that 8.i.4.c rebuilt, then resolves to a motion Action::Invoke. Slice 8.i.4.h.
keymap_handle_lookup_with_one_minor980ns~1µs / <3µsK.1.c hot path with 1 active minor mode (review R1, 2026-06-02). Single-chord lookup pays ArcSwap::load × 2 + KeymapTrie::merge_over × 2 (always_on base + 1 minor overlay) + trie walk. ~22× the no-minor keymap_handle_lookup_single row -- the entire delta is the composite-fold work the K.1.c slow path does on every keystroke when any minor mode is active.
keymap_handle_lookup_with_two_minors1.07µs~1.1µs / <3µsK.1.c hot path with 2 active minors. One extra merge_over over the 1-minor row; the slope holds at ~50ns per additional minor merge.
keymap_handle_lookup_with_three_minors_three_chord1.18µs~1.2µs / <5µsK.1.c hot path worst realistic case: 3 active minors × 3-chord lookup (diw). Three composite merges + a 3-descent trie walk. Combined with keychord_from_event (~2 ns) the full keystroke path at 3 active minors is ~1.2 µs vs. the one-frame ceiling (8.3 ms at 120 Hz) — 99.986% headroom. If this row regresses past ~10 µs the K.1.c memoized-composite-cache follow-up (review R4) becomes load-bearing.
keymap_handle_lookup_empty_minors_with_layers_registered36.4ns~35ns / <80nsK.1.c fast path: 3 minor layers registered but active_modes = []. Confirms the if active_modes.is_empty() branch in lookup_with_context bypasses the composite fold; lands at the same ~36ns ballpark as keymap_handle_lookup_single, proving registered-but-inactive minors don't tax buffers that don't use them.
which_key_partial_chord_publish_unchanged1.37ns~1.5ns / <10nsThe only which-key row on the keystroke path (WK.8). Every ordinary keystroke runs the pending-chord publisher's change check; with nothing pending before or after it short-circuits on two empty slices. At 1.37 ns it is ~0.00002% of a 120 Hz frame, and ~74× cheaper than the keychord_from_event → keymap_handle_lookup_three_chord path it sits beside. A prefix keystroke additionally pays the payload build + one typed publish; everything else which-key does runs on the actor thread 300 ms after the user stopped typing.
which_key_continuations_no_modes98.8ns~100ns / <500nsOff the keystroke path (fires once, after the idle delay). Resolver on the always-on fast path — same composite as lookup_with_context, differing only in the terminal step, and it tracks keymap_handle_lookup_* as expected (~3× the single-chord lookup row, the delta being the child walk + descendant counts).
which_key_continuations_three_minors2.37µs~2.5µs / <10µsOff the keystroke path. Three active minors — the normal case with magit / diff / snippet / emacs-keys in play. Tracks keymap_handle_lookup_with_three_minors_three_chord (1.18 µs) at ~2×, the extra being the subtree walk the lookup row does not do. Both are dominated by the same composite fold, so the §13 caching follow-up would move both.
which_key_layout_grid_407.94µs~8µs / <50µsOff the keystroke path. 40 continuations laid out at 120 columns — a large prefix. Allocation-dominated (one String per cell + the joined rows). Runs once per popup open on the actor thread, 300 ms after the last keystroke, so this is ~0.1% of a frame spent where no latency path is waiting on it.

Why these targets

The keymap-architecture doc (docs/../architecture/keymap-architecture.md §4) commits to "lookup p99 < 1 µs including chord normalisation and trie walk." Slice 8.a delivers the chord-normalisation half of that budget at 1.7ns -- 60× under the target. The trie-walk half lands in slice 8.b; the combined number gets a row above this table once the KeymapTrie ships.

The keychord_to_string_* rows are not on the keystroke path -- they fire only when the editor needs a chord-string representation (:describe-key X, macro recording, future config dump). Allocation is acceptable there; sub-30ns means even a 1000-entry :keymap view renders in ~30µs total.

The *_parse_* rows fire at startup (when the built-in catalog enumerates into the registry) and on user / plugin :bind invocations. Total startup parse cost across the ~280 built-in chords is ~7 µs -- well under the cost of any single tokio task spawn.

Slice 8.i.0-8.i.4 -- dispatcher rebuild stayed in budget

Slices 8.i.0 through 8.i.4.h retired the per-Pending match body in compute_normal_action in favour of a partial_chord stack + trie lookup driven by the catalog's chord notation. The two dispatch_translate_full_* rows above measure the full round-trip a real keystroke pays through translate() -- ArcSwap load, per-mode dispatch fan-out, trie lookup with prefix, and resolved-Action materialisation. ~100 ns each, well under the 1 µs commitment, and within ~3× the bare trie-lookup numbers above (the rest is dispatcher fan-out + Action construction). The AbsorbPartialChord / AbsorbOperatorPrefix short-circuits the new dispatch shape introduces don't measurably hurt the hot path.


Cell-grid renderer (crates/lattice-host/benches/cells_worker.rs)

Anchor: ../architecture/cell-grid-renderer.md (S5 bench harness) + paramount goal #1 (one frame: ≤8.3 ms keystroke→glyph at 120Hz).

Measures lattice_host::cells_worker::recompute — the cells worker's entrypoint — across three workloads at three line counts. Viewport height fixed at 60 (chunked-mode threshold: 4 × 60 = 240 lines).

Workload100 lines1 000 lines5 000 linesFloor / Target
cells_worker_full_build (cold start)~41 µs~385 µs~1.9 ms≤2 ms@5k / ≤5 ms@5k
cells_worker_incremental_build (typing)~39 µs~63 µs~103 µs≤150 µs@5k / ≤500 µs@5k (≪1ms keystroke)
cells_worker_cache_hit (no-op publish)~33 ns~33 ns~33 ns≤50 ns / ≤100 ns

Reading the numbers:

  • cache_hit at ~33 ns confirms recompute's version-compare fast path doesn't grow with line count — exactly the expected behaviour from MatrixVersion::differs_from.
  • incremental_build is what fires on every keystroke. The 5000-line cost (~103 µs) is well under any reasonable fraction of the one-frame keystroke→glyph ceiling (8.3 ms at 120 Hz); the chunk rebuild + suffix shift scales sub-linearly because only the edit zone rebuilds, not the whole document.
  • full_build is the cold path (boot frame, buffer switch). 5000-line cost (~1.9 ms) is comfortably within a single paint budget — even on cold start the user sees content on the very next frame.

What's NOT measured here: paint_cells_row (needs a live GPUI window so it's outside the Criterion-bench surface), GlyphResolver::resolve miss path (also needs a window), end-to-end keystroke→glyph latency (measured by the existing held-key probes; S6 strips those once enough confidence in the bench numbers accrues). The bench above covers the worker side of the pipeline; the paint side is hardware-bound and validated by hand-runs against an actual document buffer (paint_cells is the default for active panes after S4.final.f retired the env-var toggle).

Numbers captured: 2026-05-27, S5 first run.

H.3 — viewport-scoped (windowed) chunked matrix (2026-06-04)

Anchor: ../architecture/incremental-highlight.md + slice plan slice-plans/archive/incremental-highlight.md. Goal: highlight + cell-matrix build O(viewport), never O(file), so large files stay within the keystroke/paint budget.

Above WINDOW_CAP_LINES (2048) the chunked matrix is built only over [scroll − overscan, scroll + viewport + overscan) instead of the whole document. New headline bench cells_worker_windowed_build — a full (cold) build at a fixed 60-line viewport over docs from 5k to 100k lines, with a live syntax handle (highlight + cell materialisation both measured), clone-free harness:

cells_worker_windowed_build5 00020 00050 000100 000
before H.3d (line-start rescan)1.17 ms1.60 ms2.46 ms3.84 ms
after H.3d (memoized)~1.0 ms1.02 ms1.03 ms1.07 ms

Flat across file size = O(viewport) achieved. The bench is the artefact that earned its keep here: after H.3b windowed the cell matrix, the build was still scaling with line_count (1.17→3.84 ms). Root cause was not the cells layer — it was lattice-syntax::highlight_lines_via_query calling compute_line_starts(&self.source) on every call (two full O(source) passes to rebuild the line→byte table). H.3d memoizes that table on SyntaxSnapshot (recomputed once per source mutation), collapsing the curve to flat ~1 ms regardless of size. (bucket_inlays_by_line was also moved from a dense Vec<Vec<_>> of length line_count to a HashMap — O(inlays) not O(file) — though it was not the dominant term here.)

Stale rows above: the cells_worker_full_build / cells_worker_incremental_build 5 000-line figures (~1.9 ms / ~103 µs) are pre-H.3 — at 5 000 > 2048 the matrix now windows, so the real post-H.3 cold-build cost at 5 000 lines is the windowed ~1 ms (see table above), and incremental likewise touches only the windowed chunk set. The 100/1 000-line rows are unaffected (below the cap → full residency, unchanged).

Numbers captured: 2026-06-04 (--measurement-time 4).

FW.1 — the collapsed screen (2026-08-31)

Every bench above runs foldenable: false, which is exactly why this cost stayed invisible. The matrix window is sized in buffer lines the viewport reaches; with nothing folded that equals viewport_height, so no bench ever separated the two. FW.1 makes the window fold-aware (it previously stopped a screenful of lines down and left everything below the first fold uncoloured), and new bench cells_worker_folded_build puts the resulting cost on the ratchet — a cold build over a 5 000-line doc at a 60-row viewport, varying how many lines each closed fold swallows:

cells_worker_folded_buildfold_size_1 (none folded)fold_size_40 (~2 400-line span)fold_size_80 (span saturates the doc)
FW.11.58 ms52.3 ms52.1 ms

The fold-free column is the unchanged path — the fold-aware span short-circuits to scroll + viewport_height when nothing is closed, so it matches cells_worker_windowed_build/5 000 as it should.

The 33× is the highlight query, not the rows. Folded interiors never materialise a row (build_display_rows skips them), so row count and memory stay O(viewport); it is highlight_lines(win_lo, win_hi) that runs over every line the window spans, including the ones the folds hide. Measured directly on a 5 000-line Rust fixture:

SyntaxSnapshot::highlight_lineswhole 5 000 lines180-line window (fold-free)60 single-line calls (one per visible row)
52.2 ms1.82 ms828 µs

That the 60 scattered single-line calls beat even the contiguous fold-free window is the number that decided the follow-up: querying per visible run rather than per window. Tracked as FW.2 and landed immediately after; FW.1 shipped the correctness fix alone so the two bisect apart.

FW.2 — querying only the visible runs (2026-08-31)

highlight_lines costs what its RANGE costs, whether or not the lines in it are ever drawn. FW.1's fold-aware window spans every line the folds hide, and build_display_rows skips those — so the query was paying for spans nothing would read. FW.2 queries each visible run (the maximal non-hidden ranges) instead, stitching them into the same dense spans_base-indexed vector, so nothing downstream changed.

cells_worker_folded_buildfold_size_1fold_size_40fold_size_80
FW.11.58 ms52.3 ms52.1 ms
FW.21.54 ms2.33 ms1.93 ms
—unchanged22× faster27× faster

The fold-free column is unchanged because it takes the identical code path. With nothing collapsed there is exactly one run covering the whole window, and that case short-circuits to the single pre-FW.2 call — no stitching, no dense allocation. An ordinary buffer cannot pay for a feature it does not use, and the bench is what says so rather than the comment claiming it.

Why the per-run cost stays bounded: every boundary between two runs is a closed fold, and every closed fold in the window spends one of the viewport's rows on its head. So there are at most viewport_height + 1 runs no matter how many lines the folds hide — the reason this is not trading one large cost for an unbounded number of small ones. The remaining ~0.4–0.8 ms over the fold-free case is per-call query setup across those ≤61 runs, which matches the 828 µs measured for 60 single-line calls above.

Net against the pre-FW.1 baseline: a collapsed org file now paints with correct syntax colour for ~0.4 ms more worker time than the broken version spent painting it wrong.

Numbers captured: 2026-08-31 (--warm-up-time 1 --measurement-time 3).


"Performance has regressed" warnings

Criterion reports several regressions vs. its stored baseline. The baseline was captured before the actor refactor (commit 6d1bb24): buffer mutations now route through a tokio mailbox (~80µs round- trip) instead of a direct sync apply_edit (~5µs).

This is the architectural cost we accepted, not a code regression to fix. Specifically:

  • Small-file motions / operators (10-line buffers) show 15–30% regression because the 80µs actor overhead dominates the few-µs buffer-walk cost.
  • Large-file (50k-line) regressions compress (or vanish) because buffer work dominates.

Phase 4–7 work (LSP, plugin host) requires the actor; the regression is what enabled them. The motions/operators benches measure the full path through the dispatcher.

After the actor refactor lands as the new baseline, future runs should not show these regressions; criterion's stored baseline can be reset by deleting target/criterion/.


Improvement paths (prioritized)

  1. 🔼 Motion SIMD prefilters. word_forward, first_non_blank, find_char_forward would benefit from memchr the same way search did. ~30 LOC each. Not blocking §8.2 today; ladder for "decisively better than neovim" framing.

  2. 🔼 with_snapshot<R>(f) API on DocumentHandle. Read-only paths drop from 17ns to ~5ns via ArcSwap::load() -> Guard<T>. Renderer-side Cache for ~2ns post-cache loads. Worthwhile when GPU rendering arrives and per-frame snapshot overhead matters.

  3. 🔼 Frame-render bench. compose_visible_lines itself isn't measured; we only have its parts. Bench would close §8.2 row "Frame render (code, 1080p) <2ms".

  4. 🔼 Cmdline completion popup bench. Vertico-style live filter isn't on the bench. Should be sub-millisecond on the registered command set.

  5. 🔼 Tree-sitter incremental reparse bench. §8.2 commits to <1ms p99 on a 50k-line file -- unmeasured today. Now that the parser is owned by Syntax (post Option B), the seam for Parser::parse(.., Some(&old_tree)) exists; need to thread Tree::edit deltas from the document actor first.

5a. 🔼 Native highlighter bench. Syntax::highlight_lines_native isn't measured directly. Worth adding a per-language bench that isolates parse + query traversal so future regressions on the single-parse architecture surface in CI.

  1. 🔼 Open-100MB-log file bench. §8.2 commits to <100ms first paint, <500ms full ready -- unmeasured.

  2. 🔼 Dispatch round-trip via DocumentHandle::dispatch. The motion + effect commit path (vs. apply_edit alone). Closes the "what does a real keystroke cost?" question.

  3. 🔼 Suffix-array search index (months of work; deferred). The only credible path to microsecond full-buffer scans on 200k-line corpora. ~5× memory cost; rebuild on every edit.

  4. 🔼 Allocation discipline check (../architecture/design.md §A.6). Per-keystroke alloc count via dhat-rs. Catches refactor regressions before wall-clock benches do.

  5. 🔼 Long-running session bench (§A.6). 10K random invocations; assert no monotonic memory growth.


I1.1 — tick-callback registry run_all (2026-06-23)

The IDE-protocol tick-callback registry (lattice-mode::tick_callback) is the one new generic host primitive: a mode registers an FnMut() -> Vec<Effect> drain closure, and Editor::run_tick_pending calls TickCallbackRegistry::run_all once per editor tick to run them all and apply the returned effects. run_all is a single Mutex lock + an O(registered-drains) walk. This runs on the async-landed / Tick path (not the keystroke path), but it's per-tick, so the cost must stay flat.

Bench: crates/lattice-mode/benches/tick_callback.rs (tick_callback_run_all/<N>), N registered drains each returning one Effect.

registered drainsrun_all (median)Notes
0~9.4 nsBoot steady state — no mode has registered a drain. Just the lock + empty walk.
1~18.8 nsOne drain (the typical single-IDE-peer shape).
8~99 nsLinear in drain count.
32~0.4 µsStress shape; still ~20,000× under the 8.3 ms one-frame ceiling.

Flat and negligible: even the 32-drain stress case is four orders of magnitude under one display frame, and the common 0/1-drain cost is sub-20 ns. A regression (per-call allocation blow-up, lock-contention change) surfaces here in CI.


Plugin host — instantiation smoke (crates/lattice-plugin-host/benches/instantiate.rs)

PH7.0 scaffold bench. Measures the two component-load paths against the hand-written no-op lifecycle component (the degenerate init.rs): a warm instantiate of a pre-compiled component, and a cold compile+instantiate. Neither is a gated budget yet — the per-call (< 500 ns p99) and cold-start (50 plugins < 30 ms) ratchets from plugin-host.md §7 land at PH7.1 / PH7.5. This row exists so the surface is measured from day one (four-artefact discipline); it will be re-baselined on the canonical box when the gate lands.

⚠️ Provisional — off-box numbers. Unlike every other row here (Ryzen 7 9700X / WSL2), these were captured on a macOS dev machine, so they are not comparable to the §8.2 hardware baseline. Treat as order-of-magnitude only until re-run on the canonical box.

WorkloadProvisional (macOS)Notes
plugin_instantiate_noop (warm)~1.6 µsInstantiate a pre-compiled component into a fresh Store (async as of PH7.1a). The per-invocation cost the lazy-instantiation model (PH7.1b) pays on a plugin's first contribution call.
plugin_compile_instantiate_noop (cold)~300 µsAOT compile (Cranelift) + instantiate. The path a fresh component takes on a cold cache.
load_50_plugins_warm_cache (PH7.1b)~20 msLoading 50 distinct plugins from a warm on-disk cache (all hits, no recompile) — the cold-start-load path a relaunch takes. Under the 30ms/50 budget. For these trivial components the cache-hit deserialize is comparable to a cold compile; the cache win scales with real (larger) plugins.
instantiate_50_plugins (PH7.1b)~76 µsInstantiating 50 plugins from one compiled component (lazy-instantiation cost).

PH7.1a additionally covers fuel/epoch trapping, parallel-on-two-cores, and off-actor-thread execution via tests/runtime.rs; PH7.1b covers cache hit/miss + lazy load via tests/cache.rs (correctness, not benches). The per-call overhead ratchet and the cold-start gate from plugin-host.md §7 land at PH7.5.

Plugin host — boundary conversion (crates/lattice-plugin-host/benches/boundary.rs)

PH7.3a/b bench. Measures the per-value marshalling cost of the WitBoundary adapter (to_wit then from_wit) for the representative boundary types. This is only the marshalling component of the §7 "typed host function call" budget (< 100 ns p50 / < 500 ns p99); the end-to-end guest↔host typed-call gate — which also pays the wasmtime canonical-ABI lift/lower + the async suspend — lands with the call machinery at PH7.3d, where there is an actual call to measure. Not a gated CI budget yet (that is PH7.5); this row exists so the marshalling surface is measured from day one (four-artefact discipline).

⚠️ Provisional — off-box numbers. Same caveat as the instantiation smoke above: captured on a macOS dev machine, not comparable to the §8.2 hardware baseline. Order-of-magnitude only until re-run on the canonical box.

WorkloadProvisional (macOS)Notes
boundary_args_round_trip (PH7.3a)~21–47 nsA 4-element Args::List (string/int/bool/chord).
boundary_raw_candidate_round_trip (PH7.3a)~21–47 nsA RawCandidate with a File data payload.
boundary_picker_outcome_round_trip (PH7.3a)~21–47 nsA PickerAcceptOutcome::JumpToLocation.
boundary_effect_round_trip (PH7.3b1b)~92 nsA composite Effect::Many of 4 arms (RecordJump + OpenBufferAt + QuitEditor + Echo) — exercises the list<effect> flatten/rebuild + a spread of payload records. The cost an operator/ex-command guest export pays to return an effect.
boundary_app_effect_round_trip (PH7.3b2)~13 nsA single AppEffect (EnterVisual(Linewise)) — the payload of the Effect::AppAction arm. Exercises the reused ModalState/VisualKind mirrors + the app-effect variant a chord-bound plugin action marshals.
document_get_text_range_one_line (PH7.3c)~400 nsThe document resource slicing one line out of a 10k-line buffer. Demonstrates "zero-copy at the slice level" (§9.6): the cost is O(log n) locate + O(slice) copy, NOT O(document) — the whole rope is never materialised across the boundary.
boundary_picker_candidate_with_marginalia_round_trip (PH7.4a)~290 nsA picker RawCandidate carrying a Styled permission cell (2 slot-keyed segments) + a Custom size cell — the shape a plugin file source emits per row. Marshals the whole marginalia surface the picker seam adds (the Annotation enum crosses so plugin sources define columns).
boundary_routing_payload_round_trip (PH7.4a)~5–10 nsThe per-candidate RoutingPayload::OpenFile a file source emits + consumes in accept.

Plugin host — end-to-end typed call (crates/lattice-plugin-host/benches/trampoline.rs)

PH7.3d bench — the §7 headline gate "typed host function call < 100 ns p50 / < 500 ns p99", deferred from PH7.3a (which benched only the marshalling component) to here, where a real wasm32-wasip2 guest export exists to call. This is the full round trip the §4.1 trampoline pays: a host→guest call across the canonical ABI (lower the args, run the guest, lift the returned list<effect>), measured WARM (the guest is instantiated once, the call runs in a tight loop — so it is per-call overhead, not instantiation). It validates the whole effect mirror crossing a live component boundary (§14's highest risk), not a stub. Skips when the wasm32-wasip2 target isn't installed (see build.rs); CI installs it so the gate runs. Not a CI ratchet yet (PH7.5).

Plugin host — host-services fs walk seam (PH7.4b)

No dedicated microbench: the walk cost is OS-bound — it is the native directory traversal (walk_files_for_picker, the same the first-party files source already pays), plus a negligible per-call capability gate (a canonicalize

  • starts_with over the grant's fs prefixes). A criterion microbench would measure the operating system's read_dir, not any WASM-boundary overhead, so adding one would be misleading coverage rather than real coverage. The list<string> result marshalling is characterized by the boundary-conversion rows above (per-path ≈ single-digit ns). The guest→host call overhead — the part that is genuinely new and ours — is benched at PH7.4d, where the real fuzzy-finder guest calls walk across the canonical ABI (the PH7.3d host→guest precedent, from the other direction); the CI-gated per-call budget lands at PH7.5.

⚠️ Provisional — off-box. Captured on the same non-canonical box as the rows above; order-of-magnitude only until re-run on the §8.2 hardware.

WorkloadProvisionalNotes
trampoline_apply_effect_warm_call (PH7.3d)~437 ns medianargs in → list<effect> out through the fixture guest — the operator/motion apply shape. Right at the < 500 ns p99 target for a real end-to-end typed call.
trampoline_next_batch_warm_call (PH7.3d)~sub-µsOne next-batch pull (the §4.3 result-carrier's per-batch call); same order as the apply call.

Plugin host — fuzzy-finder picker path + §7 perf gates (benches/fuzzy_finder.rs, PH7.4d/7.5)

The ⭐ Phase-7 exit-gate path: the fuzzy-finder validation plugin's warm init through the PickerClient bridge — channel hop + guest export + guest→host walk round-trip + candidate-pair marshalling back. PH7.5 turns the exercised §7 rows into a CI gate — but the gate is a test (tests/perf_ratchet.rs), not a criterion compare: cargo test --workspace asserts a warm op stays under a generous absolute ceiling (orders of magnitude over the release cost, so it catches a gross regression without flapping on the ~20% GitHub-runner variance or debug inflation), mirroring lattice-host's keystroke ratchet. The criterion numbers below stay the descriptive record. wasm32-wasip2 is installed in the CI test job so the gate runs there; it skips gracefully without the target.

⚠️ Provisional — off-box. Same caveat as the rows above.

WorkloadProvisionalNotes
fuzzy_finder_init_warm_50_files (PH7.4d)~110 µsWarm init over a 50-file tree through the full bridge. The walk + 50-pair marshalling dominate; the per-call bridge overhead (channel mpsc + oneshot) is sub-µs. The guest→host call-overhead baseline (walk), the PH7.3d trampoline from the other direction.
completion_generate_warm (PH7.6)~47 µsWarm completion generate through the CompletionClient bridge (channel + guest export + 4-candidate marshalling, NO walk). The async-produce generator (option A) — a WASM completion source produces off the keystroke path, then the native match_and_rank runs. Lower than the fuzzy-finder row (no fs walk); isolates the bridge + produce cost.
typed_call_stays_within_ceiling ratchet (PH7.5)debug ~2 µs / ceiling 50 µs§7 typed host call (< 500 ns p99 release). Gates the canonical-ABI lift/lower on the trampoline fixture.
picker_init_round_trip_stays_within_ceiling ratchet (PH7.5)debug ~130 µs / ceiling 20 ms§7 guest→host picker path. Gates the channel + walk + marshalling against an O(file) blowup / lost-cache re-instantiation.
cold_start_50_instantiations_stays_within_ceiling ratchet (PH7.5)debug ~200 µs / ceiling 2 s§7 cold-start (50 plugins < 30 ms release). Gates per-instantiation cost. Descriptive number: instantiate.rs instantiate_50_plugins.
renderers_do_not_directly_depend_on_the_plugin_host guard (PH7.5)structuralThe no-per-frame-WASM rule (paramount #4): asserts lattice-ui-tui/lattice-ui-gpui don't name lattice-plugin-host as a runtime dep — a renderer that can't name a plugin can't call it on the tick.

Not gated yet (their seams don't exist): grammar-extension round-trip (PH7.7), status/gutter segment update (PH7.9), picker-filter-per-item, major-mode event handler — each lands its ratchet with its seam.


Directory listings — open + scroll (crates/lattice-host/benches/listing.rs, DL.6)

DL.4/DL.5 moved oil and the file tree off bespoke paint paths that walked O(viewport) rows by hand and onto the shared cells/DisplayMatrix build; DL.3b gave every row a leading inlay whose colour resolves through ResolvedTheme::get. Both were expected to be free — an indexed table read per row, and a build the editor already runs for every document — which is exactly the kind of expectation that should not be taken on trust.

⚠️ Provisional — off-box. Captured on a developer machine, not the §8.2 reference hardware. Order-of-magnitude, and the comparison below is the result that matters, not the absolute numbers.

WorkloadProvisionalNotes
listing_open_oil (5,000 entries)~5.9 msCold :Oil: read the directory, render the listing text, seed the Document, publish 5,000 icons. One-shot, scales with the directory.
listing_open_file_tree (5,000 entries)~7.8 msCold :Tree; the extra over oil is the root row plus per-row indent/marker construction.
listing_scroll_publish/500~6.6 µsScroll one viewport in an open listing, then republish.
listing_scroll_publish/5000~7.5 µsThe assertion is the ratio. A 10× larger directory costs ~1.15× per frame, with overlapping confidence intervals — per-frame work is flat in directory size, so nothing on the frame path scales with the listing (§8.2).

The per-frame number sits ~1000× under a 120 Hz frame (8.3 ms), so the convergence did not put listings anywhere near the budget. The open cost is one-shot and user-initiated; it is the number to watch if directory sizes grow much beyond this, and it is dominated by the filesystem read rather than by anything the editor added.

Why the scroll bench is swept rather than single-point: a path that scales with the listing instead of the viewport looks perfectly fast at any one size. Only the comparison across sizes can catch it, so the sweep is the test and either number alone would be decoration.

DL.8b re-measure (2026-09-09) — name spans are free

DL.8b adds a second per-row product at the same chokepoint: alongside the 5,000 icons, one StyledSpan per row published through PendingSyntheticHighlights and merged by merge_extra_spans at row build. That is O(rows) more work in the open path and one more span to walk per line in the build, so it had to be measured rather than argued.

Measured against a same-machine baseline (git stash -u, re-run, pop) — the absolute numbers above are from a different developer box and are not a baseline anything can be compared against:

WorkloadClean HEADWith DL.8b
listing_open_oil7.50 ms7.51 ms
listing_open_file_tree9.39 ms9.81 ms
listing_scroll_publish/50024.0 µs19.9 µs
listing_scroll_publish/500011.9 µs18.3 µs

Oil's open cost is unchanged. The tree's is +4% with confidence intervals that touch ([9.15, 9.63] vs [9.54, 10.11]) — at or below the resolution of this bench on this box, and consistent with one extra Vec per row against a cost already dominated by the filesystem read.

The scroll numbers say nothing either way and should not be read as a result. Both sides swing by 2× with overlapping intervals on this machine, including the direction that would look like an improvement — which is the tell that it is noise, not a measurement. What survives is the property the sweep exists for: per-frame cost is still flat in directory size, 5,000 entries costing no more than 500. Re-run on the §8.2 reference hardware before quoting any per-frame figure from this row.

Text reflow (crates/lattice-grammar/benches/reflow.rs, RF.1)

The engine behind gq / gw, and — at RF.3 — behind wrapping while you type. Two shapes, because they answer different questions and only one of them has a budget.

⚠️ Provisional — off-box. Developer machine, not the §8.2 reference hardware.

WorkloadProvisionalNotes
reflow_paragraph/10_lines~18.8 µsA gqap-sized fill of a doc comment.
reflow_paragraph/200_lines~365 µsThe assertion is the ratio. 20× the lines for 19.4× the time — the fill is linear, and a later change that makes it quadratic shows up here rather than on a large file.
reflow_break_point/no_break~35.8 nsThe number paramount #1 actually constrains. Every keystroke on a line that has not reached the margin pays exactly this — one width measure, no allocation, no break. 0.0004% of a 120 Hz frame.
reflow_break_point/breaking~1.26 µsThe rare frame where the line does wrap: scan for the break point and build the continuation. ~0.015% of a frame.
reflow_break_point/reflow_one_line~2.21 µsThe operator's per-line cost, for comparison — auto-wrap is cheaper than running the full fill on one line, which is why it is a separate entry point rather than gq on the current line.

The paragraph numbers are user-initiated and have no frame budget; they exist so the linearity claim is measured rather than assumed.

The break-point numbers are the ones with a budget, and the split matters: no_break is the case that runs on essentially every keystroke in Insert mode, and at 35.8 ns it is noise against everything else on that path. breaking fires once per wrapped line. Together they are the reason §9 of text-reflow.md rules out a tree-sitter query for the "is this a comment" test — the whole operation has to stay in this range, and a parse does not.

What's NOT here

Benches we'd want before claiming §8.2 coverage but haven't built yet (marked 🔼 above): frame render, completion popup, tree-sitter incremental reparse, native highlighter per-language timing, file open, dispatch round-trip.