grid: plain-text parse throughput trails ghostty-tip, most on Unicode #401

Closed
opened 2026-09-25 01:34:55 +09:00 by natsukium · 1 comment
Owner

kitten __benchmark__ (rendering suppressed, so parser and grid only) puts felis behind ghostty-tip on plain text (docs/reference/benchmarks.md, felis 9d1d842f, MB/s):

Benchmark Linux felis Linux ghostty-tip macOS felis macOS ghostty-tip
Only ASCII chars 340 392 521 545
Unicode chars 313 541 474 606
CSI codes with few chars 116 124 186 176

The Unicode gap is the largest: 42% on Linux.

Plan (perf-trace skill):

  1. Reproduce with the criterion micro-benches, split into pure dispatch, UTF-8 decode, and the full grid write, to find which layer loses.
  2. Candidates: a bulk fast path for runs of non-ASCII printable text (decode, width lookup, and grapheme break over a run instead of per codepoint), and the per-cell write cost into the grid.
  3. Keep the parser's fuzz and proptest suites green; any fast path must be pinned against the per-codepoint path.

Done when Unicode and ASCII throughput reach ghostty-tip's, or the remaining gap is explained.

`kitten __benchmark__` (rendering suppressed, so parser and grid only) puts felis behind ghostty-tip on plain text (`docs/reference/benchmarks.md`, felis `9d1d842f`, MB/s): | Benchmark | Linux felis | Linux ghostty-tip | macOS felis | macOS ghostty-tip | |---|---|---|---|---| | Only ASCII chars | 340 | 392 | 521 | 545 | | Unicode chars | 313 | 541 | 474 | 606 | | CSI codes with few chars | 116 | 124 | 186 | 176 | The Unicode gap is the largest: 42% on Linux. Plan (`perf-trace` skill): 1. Reproduce with the criterion micro-benches, split into pure dispatch, UTF-8 decode, and the full grid write, to find which layer loses. 2. Candidates: a bulk fast path for runs of non-ASCII printable text (decode, width lookup, and grapheme break over a run instead of per codepoint), and the per-cell write cost into the grid. 3. Keep the parser's fuzz and proptest suites green; any fast path must be pinned against the per-codepoint path. Done when Unicode and ASCII throughput reach ghostty-tip's, or the remaining gap is explained.
Author
Owner

Diagnosis: all three gaps are in the grid sink, not the parser

Method

  • Payloads. Kitten-shaped payloads were generated locally from kitty/tools/cmd/benchmark/main.go (kitty 4498c0028) and never committed: the same strings, the same \n\t controls, alternate screen, and the ESC[?2026 pause/resume frame per repetition. Sizes: ascii 40 MiB, unicode 36 MiB, csi 40 MiB. kitten puts the tty in raw mode, so newlines arrive as bare \n. That is the staircase regime in .agents/skills/perf-trace/references/traps.md, and the payloads keep it.
  • Harness. felis-daemon measure_parse_into_grid_floor at 35x106 (the benchmark field geometry), release build.
  • Load. Load average was 4.4–8.3 throughout (CI runner plus the GUI agent). The primary figure is therefore perf stat -e instructions:u, which is deterministic. MiB/s figures come from interleaved A/B runs (n=3, variants alternated), and each table reports load at the start of its batch.
  • Profiles. perf record -e cycles:u on the same binary (perf_event_paranoid=2 permits user-only sampling of our own process). No GUI windows were opened.

The floor already explains the kitten figures

main ef96bb46, load 7.9:

payload floor MiB/s kitten __benchmark__ MB/s (#401) ghostty-tip
ascii 361–371 340 392
unicode 341–347 313 541
csi 125–128 116 124

The single-thread parse-into-grid floor sits within 5–10% of the end-to-end numbers, so the PTY and the drain are not what separates felis from ghostty here.

Decomposition (criterion, load 6.7): pure dispatch parser_throughput/ascii_fast_path runs at 14.0 GiB/s. Parser::advance self time is 3.5% on unicode and 7% on ascii, so the state machine is not the loser. The grid sink is.

Side finding: unicode_throughput/decode_only (206 MiB/s) is slower than the full grid case (214). DecodeOnlySink (crates/felis-grid/benches/unicode_throughput.rs:79) implements only print, so it measures the per-byte decoder that the grid no longer uses. It is a stale decomposition case and should implement print_utf8_run the way the grid does.

Hot paths on main (self time, cycles:u)

unicode (337 MiB/s under the profiler):

self symbol cause
43.0% Grid::print_wide_str (crates/felis-grid/src/sink.rs:896) per-char Chars decode plus cell stores. The annotation shows self.screen.cells' data pointer and length reloaded from self on every store (sink.rs:950): the alias-poison trap. put_grapheme(&mut self) in the same body captures self, so the opaque lookup_width call inside the inner loop forces the reload.
23.5% core::str::from_utf8 (sink.rs:198) a full validation pass over the run before print_wide_str decodes it again. std's validator is slow on 3-byte CJK.
15.4% unicode_width::tables::lookup_width (two copies) bulk_width_or_defer's fallback arm (sink.rs:55), plus grapheme_width (screen.rs:339) through next_need (sink.rs:972) and put_grapheme (editing.rs:213). In the kitten text, 170 of 657 chars per repetition miss the range arms: Latin-1 (87), fullwidth forms U+FF0x (28), CJK punctuation U+300x (19), emoji (10), General Punctuation.
3.9 / 3.2 / 2.8% ClusterTable::intern, put_grapheme, grapheme_width the 14 combining marks plus emoji per repetition. This is legitimate folding work.

ascii (364 MiB/s): 60.9% Grid::print_ascii_bulk_gap (sink.rs:989). kitten's bare \n and \t put nearly every run past the row's occupancy watermark, so almost all ASCII takes the cold gap copy. The cold copy has the pointer-reload problem, and it also runs the per-cell wide-partner eviction read (sink.rs:1019) on cells that sit past the watermark and so are logically blank. The eviction load accounts for 13–14% of the function's samples. The rest: execute 7.7%, advance 7.1%, memset (scroll blank) 5.2%, rotate_region 4.7%.

csi (122 MiB/s): print_str 27.7% (the CSI 100 b REP expands to 100 cells per chunk through print_str), range_blank_inner 11.3% (EL 2, with a per-cell style_table.resolve for ISO protection at editing.rs:650), Params::push_run 8.6%, and 4.5% in HashMap<u16,bool, RandomState> (SipHash) from dec_mode_states.insert (sink.rs:889) on every CSI ?1h / ?1l.

Prototype (probe worktree, not committed) and measured gains

step change size
P1 validate the run with simdutf8::basic::from_utf8 (AVX2/NEON, runtime detected), and fall back to std::str::from_utf8 only on error for valid_up_to. simdutf8 0.1.5 is already in Cargo.lock through simd_cesu8. ~5 lines + dep
P2 print_wide_str: bind let row_cells = &mut self.screen.cells[row_base..row_base + cols] before the inner loop and store through it. ~3 lines
P3 width from a 2-bit BMP table (16 KiB, built from bulk_width_or_defer, so the existing bulk_width_matches_grapheme_width sweep still pins equivalence). The non-BMP path is unchanged. ~30 lines
P4 same row-slice binding in both ASCII loops (print_str and print_ascii_bulk_gap). ~10 lines
P5 apply_dec_private_mode: skip the dec_mode_states.insert when the stored value is unchanged. 3 lines
P7 ASCII loops: when the row chunk starts at or past the watermark, run a pure store loop with no eviction read. ~15 lines

Instructions (instructions:u for the whole test binary, two parse passes; exact across runs):

payload main P1 P1+2 P1+2+3 +P4+P5 +P7
unicode 4067M 3360M 3231M 2693M (−34%) 2693M 2693M
ascii 4044M – – 4044M 3158M 2111M (−48%)
csi 11686M – – 11686M 10683M 10722M (−8%)

Throughput, interleaved n=3, load 4.9–7.6 (MiB/s, median):

payload main prototype ghostty-tip (kitten)
unicode 335 523 (P1+2+3) 541
ascii 372 552 (P1..P7) 392
csi 115 123–128 (P1..P7) 124

The felis-grid test suite passes with the prototype, chunk-invariance proptests included: 635 lib tests plus the integration tests. A separate probe that skips the ISO-protection check in range_blank_inner cut csi instructions another 6%. In a product version that becomes a sticky "a protected style exists" flag set by DECSCA, which gates the per-cell resolve.

Proposed fix, in priority order

  1. P1 + P2 + P3 (unicode): +55% at the floor, which closes most of the 42% gap. P3 is the largest single step: lookup_width's three-level walk behind a call is what poisons the loop. The product form should be a const or build-script table, or a LazyLock dereferenced once outside the loop, instead of the prototype's per-char LazyLock deref. next_need (sink.rs:972) and put_grapheme's width should use the same table, which removes the remaining ~7% lookup_width.
  2. P4 + P7 (ascii): +48%. This also fixes the cost the traps file records for the staircase gap path.
  3. P5 plus the ISO flag (csi): +6–8%, reaching parity with ghostty-tip.

Remaining unicode gap after P1–P3: print_wide_str holds 59% self, mostly the scalar Chars decode plus 16 B cell stores. ghostty decodes the whole run to UTF-32 with SIMD (simd/vt.cpp utf8DecodeUntilControlSeq, terminal/stream.zig:712) and looks width up in a flat table (unicode.table.get(cp).width). A fused validate+decode would be the next lever, sized at ≤10%.

Gates for the real change: bulk_width_matches_grapheme_width (table equivalence), bulk_utf8_run_is_chunking_invariant, the vt_parser fuzz target, and a new criterion case built on a kitten-shaped bare-\n + \t ASCII stream, so the gap path is guarded. End-to-end confirmation belongs to python3 tools/bench/suites.py kittenbench --terminals felis on a quiet host. It was not run here because it needs a window, and the GUI agent owns the desktop.

Estimated total size: ~70 lines of product code, ~40 lines of tests/bench, and one dependency (simdutf8, which cargo deny should accept: MIT/Apache-2.0, already in the lock graph).

## Diagnosis: all three gaps are in the grid sink, not the parser ### Method - **Payloads.** Kitten-shaped payloads were generated locally from `kitty/tools/cmd/benchmark/main.go` (kitty `4498c0028`) and never committed: the same strings, the same `\n\t` controls, alternate screen, and the `ESC[?2026` pause/resume frame per repetition. Sizes: ascii 40 MiB, unicode 36 MiB, csi 40 MiB. kitten puts the tty in raw mode, so newlines arrive as bare `\n`. That is the staircase regime in `.agents/skills/perf-trace/references/traps.md`, and the payloads keep it. - **Harness.** `felis-daemon` `measure_parse_into_grid_floor` at 35x106 (the benchmark field geometry), release build. - **Load.** Load average was **4.4–8.3 throughout** (CI runner plus the GUI agent). The primary figure is therefore **`perf stat -e instructions:u`**, which is deterministic. MiB/s figures come from interleaved A/B runs (n=3, variants alternated), and each table reports load at the start of its batch. - **Profiles.** `perf record -e cycles:u` on the same binary (`perf_event_paranoid=2` permits user-only sampling of our own process). No GUI windows were opened. ### The floor already explains the kitten figures main `ef96bb46`, load 7.9: | payload | floor MiB/s | kitten `__benchmark__` MB/s (#401) | ghostty-tip | |---|---|---|---| | ascii | 361–371 | 340 | 392 | | unicode | 341–347 | 313 | 541 | | csi | 125–128 | 116 | 124 | The single-thread parse-into-grid floor sits within 5–10% of the end-to-end numbers, so the PTY and the drain are not what separates felis from ghostty here. Decomposition (criterion, load 6.7): pure dispatch `parser_throughput/ascii_fast_path` runs at 14.0 GiB/s. `Parser::advance` self time is 3.5% on unicode and 7% on ascii, so the state machine is not the loser. The grid sink is. Side finding: `unicode_throughput/decode_only` (206 MiB/s) is *slower* than the full grid case (214). `DecodeOnlySink` (`crates/felis-grid/benches/unicode_throughput.rs:79`) implements only `print`, so it measures the per-byte decoder that the grid no longer uses. It is a stale decomposition case and should implement `print_utf8_run` the way the grid does. ### Hot paths on main (self time, cycles:u) **unicode** (337 MiB/s under the profiler): | self | symbol | cause | |---|---|---| | 43.0% | `Grid::print_wide_str` (`crates/felis-grid/src/sink.rs:896`) | per-char `Chars` decode plus cell stores. The annotation shows `self.screen.cells`' data pointer and length **reloaded from `self` on every store** (`sink.rs:950`): the alias-poison trap. `put_grapheme(&mut self)` in the same body captures `self`, so the opaque `lookup_width` call inside the inner loop forces the reload. | | 23.5% | `core::str::from_utf8` (`sink.rs:198`) | a full validation pass over the run before `print_wide_str` decodes it again. std's validator is slow on 3-byte CJK. | | 15.4% | `unicode_width::tables::lookup_width` (two copies) | `bulk_width_or_defer`'s fallback arm (`sink.rs:55`), plus `grapheme_width` (`screen.rs:339`) through `next_need` (`sink.rs:972`) and `put_grapheme` (`editing.rs:213`). In the kitten text, 170 of 657 chars per repetition miss the range arms: Latin-1 (87), fullwidth forms U+FF0x (28), CJK punctuation U+300x (19), emoji (10), General Punctuation. | | 3.9 / 3.2 / 2.8% | `ClusterTable::intern`, `put_grapheme`, `grapheme_width` | the 14 combining marks plus emoji per repetition. This is legitimate folding work. | **ascii** (364 MiB/s): 60.9% `Grid::print_ascii_bulk_gap` (`sink.rs:989`). kitten's bare `\n` and `\t` put nearly every run past the row's occupancy watermark, so almost all ASCII takes the *cold* gap copy. The cold copy has the pointer-reload problem, and it also runs the per-cell wide-partner eviction read (`sink.rs:1019`) on cells that sit past the watermark and so are logically blank. The eviction load accounts for 13–14% of the function's samples. The rest: `execute` 7.7%, `advance` 7.1%, memset (scroll blank) 5.2%, `rotate_region` 4.7%. **csi** (122 MiB/s): `print_str` 27.7% (the `CSI 100 b` REP expands to 100 cells per chunk through `print_str`), `range_blank_inner` 11.3% (`EL 2`, with a per-cell `style_table.resolve` for ISO protection at `editing.rs:650`), `Params::push_run` 8.6%, and **4.5% in `HashMap<u16,bool, RandomState>` (SipHash)** from `dec_mode_states.insert` (`sink.rs:889`) on every `CSI ?1h` / `?1l`. ### Prototype (probe worktree, not committed) and measured gains | step | change | size | |---|---|---| | P1 | validate the run with `simdutf8::basic::from_utf8` (AVX2/NEON, runtime detected), and fall back to `std::str::from_utf8` only on error for `valid_up_to`. `simdutf8` 0.1.5 is already in `Cargo.lock` through `simd_cesu8`. | ~5 lines + dep | | P2 | `print_wide_str`: bind `let row_cells = &mut self.screen.cells[row_base..row_base + cols]` before the inner loop and store through it. | ~3 lines | | P3 | width from a 2-bit BMP table (16 KiB, built from `bulk_width_or_defer`, so the existing `bulk_width_matches_grapheme_width` sweep still pins equivalence). The non-BMP path is unchanged. | ~30 lines | | P4 | same row-slice binding in both ASCII loops (`print_str` and `print_ascii_bulk_gap`). | ~10 lines | | P5 | `apply_dec_private_mode`: skip the `dec_mode_states.insert` when the stored value is unchanged. | 3 lines | | P7 | ASCII loops: when the row chunk starts at or past the watermark, run a pure store loop with no eviction read. | ~15 lines | Instructions (`instructions:u` for the whole test binary, two parse passes; exact across runs): | payload | main | P1 | P1+2 | P1+2+3 | +P4+P5 | +P7 | |---|---|---|---|---|---|---| | unicode | 4067M | 3360M | 3231M | **2693M (−34%)** | 2693M | 2693M | | ascii | 4044M | – | – | 4044M | 3158M | **2111M (−48%)** | | csi | 11686M | – | – | 11686M | 10683M | **10722M (−8%)** | Throughput, interleaved n=3, load 4.9–7.6 (MiB/s, median): | payload | main | prototype | ghostty-tip (kitten) | |---|---|---|---| | unicode | 335 | **523** (P1+2+3) | 541 | | ascii | 372 | **552** (P1..P7) | 392 | | csi | 115 | **123–128** (P1..P7) | 124 | The `felis-grid` test suite passes with the prototype, chunk-invariance proptests included: 635 lib tests plus the integration tests. A separate probe that skips the ISO-protection check in `range_blank_inner` cut csi instructions another 6%. In a product version that becomes a sticky "a protected style exists" flag set by DECSCA, which gates the per-cell `resolve`. ### Proposed fix, in priority order 1. **P1 + P2 + P3 (unicode)**: +55% at the floor, which closes most of the 42% gap. P3 is the largest single step: `lookup_width`'s three-level walk behind a call is what poisons the loop. The product form should be a `const` or build-script table, or a `LazyLock` dereferenced once outside the loop, instead of the prototype's per-char `LazyLock` deref. `next_need` (`sink.rs:972`) and `put_grapheme`'s width should use the same table, which removes the remaining ~7% `lookup_width`. 2. **P4 + P7 (ascii)**: +48%. This also fixes the cost the traps file records for the staircase gap path. 3. **P5 plus the ISO flag (csi)**: +6–8%, reaching parity with ghostty-tip. Remaining unicode gap after P1–P3: `print_wide_str` holds 59% self, mostly the scalar `Chars` decode plus 16 B cell stores. ghostty decodes the whole run to UTF-32 with SIMD (`simd/vt.cpp` `utf8DecodeUntilControlSeq`, `terminal/stream.zig:712`) and looks width up in a flat table (`unicode.table.get(cp).width`). A fused validate+decode would be the next lever, sized at ≤10%. Gates for the real change: `bulk_width_matches_grapheme_width` (table equivalence), `bulk_utf8_run_is_chunking_invariant`, the vt_parser fuzz target, and a new criterion case built on a kitten-shaped bare-`\n` + `\t` ASCII stream, so the gap path is guarded. End-to-end confirmation belongs to `python3 tools/bench/suites.py kittenbench --terminals felis` on a quiet host. It was not run here because it needs a window, and the GUI agent owns the desktop. Estimated total size: ~70 lines of product code, ~40 lines of tests/bench, and one dependency (`simdutf8`, which `cargo deny` should accept: MIT/Apache-2.0, already in the lock graph).
Commenting is not possible because the repository is archived.
No description provided.