grid: plain-text parse throughput trails ghostty-tip, most on Unicode #401
Labels
No labels
priority/P0
priority/P1
priority/P2
release/v0.1.0
status/blocked
status/planned
type/bug
type/design
type/test-gap
type/tracker
No milestone
No project
No assignees
1 participant
Due date
No due date set.
Dependencies
No dependencies set
Reference
natsukium/felis#401
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
kitten __benchmark__(rendering suppressed, so parser and grid only) puts felis behind ghostty-tip on plain text (docs/reference/benchmarks.md, felis9d1d842f, MB/s):The Unicode gap is the largest: 42% on Linux.
Plan (
perf-traceskill):Done when Unicode and ASCII throughput reach ghostty-tip's, or the remaining gap is explained.
Diagnosis: all three gaps are in the grid sink, not the parser
Method
kitty/tools/cmd/benchmark/main.go(kitty4498c0028) and never committed: the same strings, the same\n\tcontrols, alternate screen, and theESC[?2026pause/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.felis-daemonmeasure_parse_into_grid_floorat 35x106 (the benchmark field geometry), release build.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.perf record -e cycles:uon the same binary (perf_event_paranoid=2permits user-only sampling of our own process). No GUI windows were opened.The floor already explains the kitten figures
main
ef96bb46, load 7.9:__benchmark__MB/s (#401)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_pathruns at 14.0 GiB/s.Parser::advanceself 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 onlyprint, so it measures the per-byte decoder that the grid no longer uses. It is a stale decomposition case and should implementprint_utf8_runthe way the grid does.Hot paths on main (self time, cycles:u)
unicode (337 MiB/s under the profiler):
Grid::print_wide_str(crates/felis-grid/src/sink.rs:896)Charsdecode plus cell stores. The annotation showsself.screen.cells' data pointer and length reloaded fromselfon every store (sink.rs:950): the alias-poison trap.put_grapheme(&mut self)in the same body capturesself, so the opaquelookup_widthcall inside the inner loop forces the reload.core::str::from_utf8(sink.rs:198)print_wide_strdecodes it again. std's validator is slow on 3-byte CJK.unicode_width::tables::lookup_width(two copies)bulk_width_or_defer's fallback arm (sink.rs:55), plusgrapheme_width(screen.rs:339) throughnext_need(sink.rs:972) andput_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.ClusterTable::intern,put_grapheme,grapheme_widthascii (364 MiB/s): 60.9%
Grid::print_ascii_bulk_gap(sink.rs:989). kitten's bare\nand\tput 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:execute7.7%,advance7.1%, memset (scroll blank) 5.2%,rotate_region4.7%.csi (122 MiB/s):
print_str27.7% (theCSI 100 bREP expands to 100 cells per chunk throughprint_str),range_blank_inner11.3% (EL 2, with a per-cellstyle_table.resolvefor ISO protection atediting.rs:650),Params::push_run8.6%, and 4.5% inHashMap<u16,bool, RandomState>(SipHash) fromdec_mode_states.insert(sink.rs:889) on everyCSI ?1h/?1l.Prototype (probe worktree, not committed) and measured gains
simdutf8::basic::from_utf8(AVX2/NEON, runtime detected), and fall back tostd::str::from_utf8only on error forvalid_up_to.simdutf80.1.5 is already inCargo.lockthroughsimd_cesu8.print_wide_str: bindlet row_cells = &mut self.screen.cells[row_base..row_base + cols]before the inner loop and store through it.bulk_width_or_defer, so the existingbulk_width_matches_grapheme_widthsweep still pins equivalence). The non-BMP path is unchanged.print_strandprint_ascii_bulk_gap).apply_dec_private_mode: skip thedec_mode_states.insertwhen the stored value is unchanged.Instructions (
instructions:ufor the whole test binary, two parse passes; exact across runs):Throughput, interleaved n=3, load 4.9–7.6 (MiB/s, median):
The
felis-gridtest 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 inrange_blank_innercut csi instructions another 6%. In a product version that becomes a sticky "a protected style exists" flag set by DECSCA, which gates the per-cellresolve.Proposed fix, in priority order
lookup_width's three-level walk behind a call is what poisons the loop. The product form should be aconstor build-script table, or aLazyLockdereferenced once outside the loop, instead of the prototype's per-charLazyLockderef.next_need(sink.rs:972) andput_grapheme's width should use the same table, which removes the remaining ~7%lookup_width.Remaining unicode gap after P1–P3:
print_wide_strholds 59% self, mostly the scalarCharsdecode plus 16 B cell stores. ghostty decodes the whole run to UTF-32 with SIMD (simd/vt.cpputf8DecodeUntilControlSeq,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+\tASCII stream, so the gap path is guarded. End-to-end confirmation belongs topython3 tools/bench/suites.py kittenbench --terminals felison 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, whichcargo denyshould accept: MIT/Apache-2.0, already in the lock graph).