grid: cache recent pens in front of the style dedup index #429

Merged
natsukium merged 8 commits from grid/pen-intern-cache into main 2026-09-25 12:29:18 +09:00
Owner

Under DOOM-fire every SGR re-interns the pen, and the dedup lookup (hash, probe, enum-wise Attributes equality) was ~24% of the parse thread's cycles on the floor bench.

Change

  • StyleTable::intern checks a 1024-slot direct-mapped cache of recent (packed attributes, StyleId) pairs before the dedup map. DOOM sends 38;5 and 48;5 as separate SGRs, so the pen changes on 99.7% of SGRs, but it cycles among ~500 pens: the cache hits ~96% of interns (256 slots: 78%; 4096 buys 0.1% more).
  • The dedup map is keyed by the same two packed u64 words (PenKey), so a probe compares two words instead of three Color enums. That keeps the truecolor flood, which misses the cache on every cell, below its old instruction count.
  • compact clears the cache, because compaction is the one place ids get renumbered. StyleTable's PartialEq compares entries only, since dedup and the cache only index them.
  • felis-json row transcoding builds one StyleTable per RowDelta message instead of one per row, so it does not zero the 24 KiB cache once per row.

Rejected: skipping the intern when the SGR leaves the pen unchanged costs +2% instructions on doom, because the pen almost always changes. A single last-hit entry never hits. A lazily allocated cache cost +0.4–1.5% instructions across the floors for its per-intern bounds check. The decision is recorded in docs/explanation/data-model/grid-and-cells.md "Style interning", and the figures are in the perf-trace cost map.

Measurements

measure_parse_into_grid_floor, 35×106, interleaved A/B, instructions:u (deterministic):

floor origin/main (9fec9dbc) this branch Δ
doom 9409 M 8833 M −6.1%
csi 8833 M 8602 M −2.6%
truecolor 4476 M 4378 M −2.2%
ascii 2064 M 2064 M 0

MiB/s, doom: 214 → 236 (load 11–24). Before the merge of main, at load 11–14: doom 203 → 219, csi 138 → 149, truecolor 287 → 285, ascii 509 → 513. Intern's share of doom parse cycles fell from ~24% to ~15%. What remains there is the slot load's latency and the three Color packs, not the map.

Tests

  • style_table::tests::the_recent_cache_returns_what_the_dedup_index_would: a proptest of the cached intern against the uncached path, with colliding slots (2304 possible pens, 1024 slots) and interleaved compactions.
  • table_gc::tests::sweeps_are_invisible_to_what_is_printed_afterwards now adds a small repeating palette (so a pen comes back after a compaction and hits the cache) and RIS, on top of the DECSC/DECRC and alt-screen steps it already had.
  • Both tests fail when the compact clear is removed.
  • json_v1::row::tests::rows_sharing_a_table_recode_as_rows_with_their_own.

Closes #416

Under DOOM-fire every SGR re-interns the pen, and the dedup lookup (hash, probe, enum-wise `Attributes` equality) was ~24% of the parse thread's cycles on the floor bench. ## Change - `StyleTable::intern` checks a 1024-slot direct-mapped cache of recent `(packed attributes, StyleId)` pairs before the dedup map. DOOM sends `38;5` and `48;5` as separate SGRs, so the pen changes on 99.7% of SGRs, but it cycles among ~500 pens: the cache hits ~96% of interns (256 slots: 78%; 4096 buys 0.1% more). - The dedup map is keyed by the same two packed `u64` words (`PenKey`), so a probe compares two words instead of three `Color` enums. That keeps the truecolor flood, which misses the cache on every cell, below its old instruction count. - `compact` clears the cache, because compaction is the one place ids get renumbered. `StyleTable`'s `PartialEq` compares `entries` only, since `dedup` and the cache only index them. - felis-json row transcoding builds one `StyleTable` per `RowDelta` message instead of one per row, so it does not zero the 24 KiB cache once per row. Rejected: skipping the intern when the SGR leaves the pen unchanged costs +2% instructions on doom, because the pen almost always changes. A single last-hit entry never hits. A lazily allocated cache cost +0.4–1.5% instructions across the floors for its per-intern bounds check. The decision is recorded in `docs/explanation/data-model/grid-and-cells.md` "Style interning", and the figures are in the perf-trace cost map. ## Measurements `measure_parse_into_grid_floor`, 35×106, interleaved A/B, `instructions:u` (deterministic): | floor | origin/main (9fec9dbc) | this branch | Δ | |---|---|---|---| | doom | 9409 M | 8833 M | −6.1% | | csi | 8833 M | 8602 M | −2.6% | | truecolor | 4476 M | 4378 M | −2.2% | | ascii | 2064 M | 2064 M | 0 | MiB/s, doom: 214 → 236 (load 11–24). Before the merge of main, at load 11–14: doom 203 → 219, csi 138 → 149, truecolor 287 → 285, ascii 509 → 513. Intern's share of doom parse cycles fell from ~24% to ~15%. What remains there is the slot load's latency and the three `Color` packs, not the map. ## Tests - `style_table::tests::the_recent_cache_returns_what_the_dedup_index_would`: a proptest of the cached `intern` against the uncached path, with colliding slots (2304 possible pens, 1024 slots) and interleaved compactions. - `table_gc::tests::sweeps_are_invisible_to_what_is_printed_afterwards` now adds a small repeating palette (so a pen comes back after a compaction and hits the cache) and RIS, on top of the DECSC/DECRC and alt-screen steps it already had. - Both tests fail when the `compact` clear is removed. - `json_v1::row::tests::rows_sharing_a_table_recode_as_rows_with_their_own`. Closes #416
Every SGR re-interns the pen, and under DOOM-fire that lookup (hash,
probe, enum-wise equality) was about a fifth of the parse thread.
DOOM sends `38;5` and `48;5` as separate SGRs, so the pen changes on
99.7% of them, but it cycles among ~500 distinct pens. A 1024-slot
direct-mapped cache keyed by the packed attribute words hits ~96% of
those interns; 256 slots hit 78%, 4096 buy almost nothing more.

The dedup map is now keyed by the same packed words, so a probe
compares two u64s instead of three Color enums. That is what keeps a
fresh-pen-per-cell truecolor flood, which misses the cache every
cell, below its previous instruction count.

`compact` clears the cache: it is the one place ids are renumbered,
and a cached id from before it would resolve to another pen.

Skipping the intern when the SGR left the pen unchanged was measured
and rejected: +2% instructions on the DOOM floor, since the pen almost
always changes.

measure_parse_into_grid_floor, 35x106, interleaved, load 11-14:
  doom       10350 -> 9774 M instructions (-5.6%), 203 -> 219 MiB/s
  csi         9315 -> 9084 M (-2.5%), 138 -> 149 MiB/s
  truecolor   5257 -> 5159 M (-1.9%), 287 -> 285 MiB/s
  ascii       2048 -> 2048 M, 509 -> 513 MiB/s
Intern's share of DOOM parse cycles: ~24% -> ~15%.

Assisted-by: Claude Code
Records the measured pen-intern breakdown so the next pass on the SGR regime starts from it: why a skip-if-unchanged check loses, how the cache size was chosen, and what remains in intern after the cache (the slot load and the Color packs, not the map).

Assisted-by: Claude Code
Box: :new over a 1024-slot array materializes 24 KiB on the stack before moving it, which clippy's large_stack_arrays lint (part of just check) refuses; building it from a vec avoids the copy. recent_index can also be const.
Assisted-by: Claude Code
Each StyleTable now zeroes a 24 KiB recent-pen cache when built, and the felis-json row transcoding built a fresh table per row in both directions, so a full-screen delta paid that once per row for a cache no row reuses. The rows of one message now share a table; the wire row carries resolved attributes, so a row recodes identically whatever the rows before it interned, which the new test pins.

Assisted-by: Claude Code
# Conflicts:
#	.agents/skills/perf-trace/references/cost-maps.md
skills: restate the pen-cache figures over the register push_run
Some checks failed
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion full-suite snapshot (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Failing after 2m28s
darwin / detect relevant changes (pull_request) Successful in 4s
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 53s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m8s
pr / nix flake check (pull_request) Failing after 35s
pr / cargo build / clippy / test / deny (pull_request) Failing after 15s
pr / wire schema is compatible with the base (pull_request) Successful in 19s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m34s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 4s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo nextest (Windows) (pull_request) Failing after 1m54s
windows / cargo clippy (Windows cross) (pull_request) Has been cancelled
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
fffdaf23b1
The parameter decode that landed on main alongside this change moved every floor's baseline, so the recorded deltas are re-measured against it (interleaved, instructions:u; MiB/s only for doom, the one floor the load left readable).

Assisted-by: Claude Code
Merge remote-tracking branch 'origin/main' into grid/pen-intern-cache
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
darwin / detect relevant changes (pull_request) Successful in 13s
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 53s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 13s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo nextest (Windows) (pull_request) Successful in 4m15s
bench / Criterion regression gate (pull_request) Failing after 2m20s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m12s
pr / nix flake check (pull_request) Successful in 37s
pr / cargo build / clippy / test / deny (pull_request) Failing after 1m37s
pr / wire schema is compatible with the base (pull_request) Successful in 12s
windows / frontend smoke (Windows) (pull_request) Has started running
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m31s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
3c0aad212a
Merge remote-tracking branch 'origin/main' into grid/pen-intern-cache
Some checks failed
windows / frontend smoke (Windows) (pull_request) Has been skipped
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Failing after 2m15s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m29s
pr / nix flake check (pull_request) Successful in 32s
pr / cargo build / clippy / test / deny (pull_request) Failing after 2m0s
pr / wire schema is compatible with the base (pull_request) Successful in 19s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 2m27s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 26s
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / detect relevant changes (pull_request) Successful in 14s
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 53s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 16s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo nextest (Windows) (pull_request) Failing after 2m25s
929844ff00
natsukium deleted branch grid/pen-intern-cache 2026-09-25 12:29:18 +09:00
Commenting is not possible because the repository is archived.
No description provided.