grid: close the plain-text parse throughput gap #410

Merged
natsukium merged 10 commits from grid/plain-text-throughput into main 2026-09-25 03:29:05 +09:00
Owner

The #401 diagnosis traced felis's plain-text gap to ghostty-tip to the grid's print paths, not the parser. This branch removes the costs the profile named, and a new equivalence proptest turned up three existing bugs in those paths, fixed here too.

What changed

  • UTF-8 validation: print_utf8_run validates with simdutf8::basic (runtime AVX2/SSE4.2/NEON) and uses std only on an invalid run, to get valid_up_to. The std validator was 23.5% of the unicode floor's cycles. simdutf8 is MIT/Apache-2.0 and was already in the lock graph.
  • Width lookup: widths come from a BMP table with 2 bits per scalar (16 KiB, built once from bulk_width_or_defer). The table serves print_wide_str's batch loop, its wrap check, and grapheme_width, and the loop stores through a row slice. The opaque unicode-width call inside the store loop was the alias poison traps.md describes: it forced the loop to reload the cells pointer on every store.
  • ASCII store loops: print_str and its gap fork now share one free function over the row slice. It probes for wide-partner eviction only below the row's occupancy watermark and plain-stores everything past it. kitten's bare-\n staircase lands nearly every line past the watermark. The gap blank copies from a static blank array, because fill(Cell::default()) compiled to a loop that stores each cell separately instead of to a memset.
  • DEC mode soft-track: dec_mode_states hashes with foldhash FixedState and updates in place with a single get_mut.
  • Benches: decode_only now decodes through the batched entry points, as the grid does. It had been measuring the old per-byte decoder, which made it slower than the full grid case. The new ascii_throughput bench has two cases, bare_lf_staircase and crlf_lines.

Bugs found by the new proptest (all present on main)

batched_print_matches_per_byte_print feeds the same streams to two grids and compares the results: one grid gets the batched print_str/print_utf8_run paths at random read-chunk sizes, the other gets per-byte Sink::print. The streams mix ASCII, wide, fold-candidate and invalid UTF-8 runs with LF/CR/HT/BS, CUP, EL, SGR, REP, the alternate screen, DECAWM and IRM.

  1. Stale Spacer: on a scroll-recycled alt-screen row, the ASCII loop read a stale Spacer past the watermark and blanked the live cell to its left. The fix is the watermark split above.
  2. Emoji modifier wrap: when a UTF-8 run ended one column short of the edge, it armed a wrap sized for the next scalar's standalone width, even when that scalar was a fold candidate such as an emoji modifier. The modifier then wrapped instead of joining the run's last glyph.
  3. ZWJ across ASCII: print_str left zwj_pending armed across an ASCII run, so a later scalar could join a cluster the run had already ended.

Each bug also has a deterministic test. CHANGELOG has a Fixed entry.

Measured

The parse-into-grid floor is felis-daemon's measure_parse_into_grid_floor: kitten-shaped payloads at 35x106, release build, two passes. Instruction counts (instructions:u) are exact across runs. MiB/s is the median of 3 runs with main and this branch interleaved. Load average was 8.1–8.2, because this host is also the CI runner.

payload instructions main → branch floor MiB/s main → branch ghostty-tip (kitten, #401)
unicode 4067M → 2556M (−37%) 281 → 488 (+74%) 541
ascii 4044M → 2282M (−44%) 298 → 430 (+44%) 392
csi 11686M → 10236M (−12%) 114 → 124 (+9%) 124

Criterion, same host, load ~7–8, main → branch:

  • ascii_throughput/bare_lf_staircase: 326 → 487 MiB/s
  • ascii_throughput/crlf_lines: 361 → 586 MiB/s
  • unicode_throughput/cjk_emoji_combining: 191 → 270 MiB/s

End-to-end kittenbench needs a window and a quiet host, so it was not run.

Not in this PR

  • ISO-protection check: range_blank_inner resolves ISO protection for every cell it blanks (a further ~6% of csi instructions). Filed as #409.
  • SIMD decode: fused validate+decode to UTF-32, sized at ≤10% of the unicode floor. It is not filed, because the unicode floor is already within noise of ghostty-tip's kitten figure.

Checks

  • just check passes: 3552 tests, including the full felis-grid suite and the new proptest at 120k cases locally.
  • fuzz/ checks clean, and its lock now carries simdutf8.
  • Review: an independent luna pass returned PASS with no findings. An independent sol pass returned PASS with one non-blocking finding: the decode_only doc comment overstated what the delta measures. That comment is fixed.

Closes #401

The #401 diagnosis traced felis's plain-text gap to ghostty-tip to the grid's print paths, not the parser. This branch removes the costs the profile named, and a new equivalence proptest turned up three existing bugs in those paths, fixed here too. ## What changed - **UTF-8 validation**: `print_utf8_run` validates with `simdutf8::basic` (runtime AVX2/SSE4.2/NEON) and uses std only on an invalid run, to get `valid_up_to`. The std validator was 23.5% of the unicode floor's cycles. simdutf8 is MIT/Apache-2.0 and was already in the lock graph. - **Width lookup**: widths come from a BMP table with 2 bits per scalar (16 KiB, built once from `bulk_width_or_defer`). The table serves `print_wide_str`'s batch loop, its wrap check, and `grapheme_width`, and the loop stores through a row slice. The opaque `unicode-width` call inside the store loop was the alias poison `traps.md` describes: it forced the loop to reload the cells pointer on every store. - **ASCII store loops**: `print_str` and its gap fork now share one free function over the row slice. It probes for wide-partner eviction only below the row's occupancy watermark and plain-stores everything past it. kitten's bare-`\n` staircase lands nearly every line past the watermark. The gap blank copies from a static blank array, because `fill(Cell::default())` compiled to a loop that stores each cell separately instead of to a memset. - **DEC mode soft-track**: `dec_mode_states` hashes with foldhash `FixedState` and updates in place with a single `get_mut`. - **Benches**: `decode_only` now decodes through the batched entry points, as the grid does. It had been measuring the old per-byte decoder, which made it slower than the full grid case. The new `ascii_throughput` bench has two cases, `bare_lf_staircase` and `crlf_lines`. ## Bugs found by the new proptest (all present on main) `batched_print_matches_per_byte_print` feeds the same streams to two grids and compares the results: one grid gets the batched `print_str`/`print_utf8_run` paths at random read-chunk sizes, the other gets per-byte `Sink::print`. The streams mix ASCII, wide, fold-candidate and invalid UTF-8 runs with LF/CR/HT/BS, CUP, EL, SGR, REP, the alternate screen, DECAWM and IRM. 1. **Stale Spacer**: on a scroll-recycled alt-screen row, the ASCII loop read a stale `Spacer` past the watermark and blanked the live cell to its left. The fix is the watermark split above. 2. **Emoji modifier wrap**: when a UTF-8 run ended one column short of the edge, it armed a wrap sized for the next scalar's standalone width, even when that scalar was a fold candidate such as an emoji modifier. The modifier then wrapped instead of joining the run's last glyph. 3. **ZWJ across ASCII**: `print_str` left `zwj_pending` armed across an ASCII run, so a later scalar could join a cluster the run had already ended. Each bug also has a deterministic test. CHANGELOG has a Fixed entry. ## Measured The parse-into-grid floor is `felis-daemon`'s `measure_parse_into_grid_floor`: kitten-shaped payloads at 35x106, release build, two passes. Instruction counts (`instructions:u`) are exact across runs. MiB/s is the median of 3 runs with main and this branch interleaved. Load average was 8.1–8.2, because this host is also the CI runner. | payload | instructions main → branch | floor MiB/s main → branch | ghostty-tip (kitten, #401) | |---|---|---|---| | unicode | 4067M → 2556M (−37%) | 281 → 488 (+74%) | 541 | | ascii | 4044M → 2282M (−44%) | 298 → 430 (+44%) | 392 | | csi | 11686M → 10236M (−12%) | 114 → 124 (+9%) | 124 | Criterion, same host, load ~7–8, main → branch: - `ascii_throughput/bare_lf_staircase`: 326 → 487 MiB/s - `ascii_throughput/crlf_lines`: 361 → 586 MiB/s - `unicode_throughput/cjk_emoji_combining`: 191 → 270 MiB/s End-to-end `kittenbench` needs a window and a quiet host, so it was not run. ## Not in this PR - **ISO-protection check**: `range_blank_inner` resolves ISO protection for every cell it blanks (a further ~6% of csi instructions). Filed as #409. - **SIMD decode**: fused validate+decode to UTF-32, sized at ≤10% of the unicode floor. It is not filed, because the unicode floor is already within noise of ghostty-tip's kitten figure. ## Checks - `just check` passes: 3552 tests, including the full `felis-grid` suite and the new proptest at 120k cases locally. - `fuzz/` checks clean, and its lock now carries simdutf8. - Review: an independent luna pass returned PASS with no findings. An independent sol pass returned PASS with one non-blocking finding: the `decode_only` doc comment overstated what the delta measures. That comment is fixed. Closes #401
`print_utf8_run` validates each printable run once before
`print_wide_str` decodes it. std's validator is scalar and slow on
3-byte CJK sequences: on a kitten-shaped unicode payload it was 23.5%
of the parse-into-grid floor's cycles.

simdutf8's `basic` validator picks AVX2/SSE4.2/NEON at runtime and
reports only valid/invalid, so an invalid run falls back to std for
the `valid_up_to` position; the per-byte tail handling is unchanged.
simdutf8 is MIT/Apache-2.0 and was already in the lock graph.

Parse-into-grid floor, unicode payload (35x106, instructions:u over two
passes): 4067M -> 3360M (-17%).

Assisted-by: Claude Code
`print_wide_str`'s batch loop took each glyph's width from
`bulk_width_or_defer`, whose range arms covered the hot scripts but
fell through to unicode-width's out-of-line multi-level lookup for
everything else. In kitten's unicode text 170 of 657 chars per
repetition miss the arms (Latin-1, fullwidth forms, CJK punctuation,
emoji), and the call inside the store loop also forced the compiler to
reload `self.screen.cells`' pointer and length on every store.

The widths now come from a 2-bit-per-scalar BMP table built once from
`bulk_width_or_defer`, which drops its range arms since the table
subsumes them; non-BMP scalars still take the function. The batch loop
reads the table through a reference taken before the loop and stores
through a row slice, so nothing in the loop aliases `self`.
`grapheme_width` answers from the same table and falls back to
unicode-width only for a deferred 0, which removes the remaining lookup
cost from `put_grapheme` and the wrap check. A build script or a
checked-in const table was rejected: the table depends on
unicode-width's data, and a 16 KiB literal in the tree is worse to
review than one sweep test.

`grapheme_width_matches_unicode_width` replaces the CJK-only sweep and
pins `grapheme_width` over every scalar; `bulk_width_matches_grapheme_width`
also pins the table against `bulk_width_or_defer`.

Parse-into-grid floor, unicode payload (35x106, instructions:u over two
passes): 3360M -> 2589M (-23%).

Assisted-by: Claude Code
Both ASCII store loops (`print_str` and its gap fork) probed every
target cell for a wide partner to evict, including cells at or past the
row's occupancy watermark. Those cells hold a recycled slot's previous
tenant, not live content, so the probe was wasted work and, worse,
could act on garbage: a stale `Spacer` blanked the live cell to its
left. `x中` scrolled off the alternate screen, then `éé` and an SGR
followed by `b` on the recycled row, lost the second `é`.

The chunk loop is now one free function over the row slice: it probes
only `[start..occ)` and plain-stores the rest. Taking the row as a
slice also stops the stores from reloading `self.screen.cells`' pointer
and length each iteration. kitten's ascii benchmark writes bare `\n`
through a raw tty, so almost every line starts past the watermark on a
recycled row; that regime now runs no probe at all.

The gap blank ahead of that loop copies from a static blank row instead
of `fill(Cell::default())`, which LLVM emitted as a per-cell store of
the tag byte and the tail rather than a memset (7% of the floor's
instructions on the same payload).

`ascii_throughput` adds criterion cases for both line-ending regimes;
on this host (load ~7) against main: bare_lf_staircase 326 -> 487
MiB/s, crlf_lines 361 -> 586 MiB/s.

Parse-into-grid floor (35x106, instructions:u over two passes):
ascii 4044M -> 2280M (-44%), csi 11686M -> 10476M (-10%, the REP
expansion runs through `print_str`).

Assisted-by: Claude Code
`print_str` and `print_utf8_run` exist only to be faster than feeding
each byte through `Sink::print`, so their contract is to leave the grid
exactly as that would. Nothing checked it beyond one fixed chunking
string. `batched_print_matches_per_byte_print` now compares the two
over random streams of ASCII, wide, fold-candidate and invalid UTF-8
runs mixed with line controls, CUP, EL, SGR, REP, the alternate screen,
DECAWM and IRM, at random read-chunk sizes.

It found two divergences, both fixed here:

- A UTF-8 run that ended flush against the right edge armed a pending
  wrap for the next scalar's standalone width, even when that scalar
  was deferred to `put_grapheme` as a fold candidate. `é` followed by
  an emoji modifier in a 2-column grid then wrapped instead of folding
  the modifier into `é`. A deferred scalar needs no room, so the wrap
  check now uses the batch width, whose 0 marks exactly those.
- `print_str` never went through `put_grapheme`, so it left a ZWJ
  armed across an ASCII run, and a later scalar joined a cluster that
  the run had already ended.

Assisted-by: Claude Code
Every DECSET/DECRESET records the mode in `dec_mode_states` so DECRQM
can report modes felis does not implement. That map used std's SipHash
`RandomState` and an unconditional `insert`: 4.5% of the parse floor's
cycles on kitten's csi payload, which toggles `?1h`/`?1l` per chunk.

The map now hashes with foldhash's `FixedState`, as the style, link
and cluster tables already do, and updates an existing entry in place
through one `get_mut` probe. Its keys are 16-bit mode numbers, so
there is no attacker-sized key space to defend with a keyed hash.

Parse-into-grid floor, csi payload (35x106, instructions:u over two
passes): 10476M -> 10236M (-2.3%).

Assisted-by: Claude Code
`decode_only` is the decode-and-width half of `cjk_emoji_combining`, so
their difference reads as the grid write cost. Its sink implemented
only `print`, which left the parser's `print_str` and `print_utf8_run`
on their per-byte defaults: the case measured the byte-at-a-time
decoder the grid stopped using when it gained batched runs, and came
out slower than the full grid case (206 vs 214 MiB/s), a decomposition
that points nowhere.

The sink now validates whole runs with simdutf8 and walks their chars
as the grid does, falling back to the per-byte decoder only for an
invalid run. On this host (load ~8): decode_only 705 MiB/s against
cjk_emoji_combining 270 MiB/s.

Assisted-by: Claude Code
The argument against a wider SWAR stride divides the NoopSink dispatch
rate by the rate of the parse into the grid. The grid side now runs
430-590 MiB/s rather than ~340 (bare `\n` vs `\r\n` line endings in the
new `ascii_throughput` bench), so the scan's share moves from 1.6% to
2-3%; the conclusion stands.

Assisted-by: Claude Code
The unicode section still described the July floor and did not name
the next lever now that validation and width lookup are gone from the
profile. The ascii section listed batching the wide-partner probe as
unspent, but the probe does not run past the row watermark, which is
the whole of kitten's staircase regime. The csi floor's next lever,
the per-cell ISO-protection resolve in `range_blank_inner`, was
measured but not recorded anywhere a next pass would look.

Assisted-by: Claude Code
docs: list the batched-print fixes in the changelog
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Successful in 2m16s
darwin / detect relevant changes (pull_request) Successful in 4s
darwin / darwin docs-only no-op (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m13s
darwin / build felis (aarch64-darwin) (pull_request) Successful in 2m10s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
pr / nix flake check (pull_request) Successful in 1m21s
pr / cargo build / clippy / test / deny (pull_request) Failing after 1m11s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 3m48s
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
pr / wire schema is compatible with the base (pull_request) Successful in 13s
windows / cargo clippy (Windows cross) (pull_request) Successful in 14s
windows / cargo nextest (Windows) (pull_request) Successful in 7m35s
windows / frontend smoke (Windows) (pull_request) Successful in 1m52s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
c76161011c
The grid's batched print paths left visibly different cells from
per-character printing in three cases the new equivalence proptest
found; each shows up on screen as a lost glyph, a wrong wrap or a
wrong cluster, so users upgrading need to find them.

Assisted-by: Claude Code
natsukium force-pushed grid/plain-text-throughput from c76161011c
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Successful in 2m16s
darwin / detect relevant changes (pull_request) Successful in 4s
darwin / darwin docs-only no-op (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m13s
darwin / build felis (aarch64-darwin) (pull_request) Successful in 2m10s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
pr / nix flake check (pull_request) Successful in 1m21s
pr / cargo build / clippy / test / deny (pull_request) Failing after 1m11s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 3m48s
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
pr / wire schema is compatible with the base (pull_request) Successful in 13s
windows / cargo clippy (Windows cross) (pull_request) Successful in 14s
windows / cargo nextest (Windows) (pull_request) Successful in 7m35s
windows / frontend smoke (Windows) (pull_request) Successful in 1m52s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
to a4e583a757
All checks were successful
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Successful in 2m7s
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 55s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m4s
pr / nix flake check (pull_request) Successful in 8s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m34s
pr / wire schema is compatible with the base (pull_request) Successful in 13s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m24s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 5s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo nextest (Windows) (pull_request) Successful in 4m44s
windows / cargo clippy (Windows cross) (pull_request) Successful in 12s
windows / frontend smoke (Windows) (pull_request) Successful in 1m35s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
2026-09-25 02:59:16 +09:00
Compare
Merge remote-tracking branch 'origin/main' into grid/plain-text-throughput
All checks were successful
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 4s
darwin / darwin docs-only no-op (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Successful in 2m20s
darwin / build felis (aarch64-darwin) (pull_request) Successful in 54s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m11s
pr / nix flake check (pull_request) Successful in 35s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m45s
pr / wire schema is compatible with the base (pull_request) Successful in 12s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m20s
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 clippy (Windows cross) (pull_request) Successful in 13s
windows / cargo nextest (Windows) (pull_request) Successful in 3m44s
windows / frontend smoke (Windows) (pull_request) Successful in 1m28s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
a494c2344d
natsukium deleted branch grid/plain-text-throughput 2026-09-25 03:29:05 +09:00
Commenting is not possible because the repository is archived.
No description provided.