Skip to content

perf: PTY drain ingest overhaul — line-batch fusion, ring grid, byte-span sealing#29

Merged
simota merged 27 commits into
mainfrom
wish4/converge
Jul 19, 2026
Merged

perf: PTY drain ingest overhaul — line-batch fusion, ring grid, byte-span sealing#29
simota merged 27 commits into
mainfrom
wish4/converge

Conversation

@simota

@simota simota commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Summary

TerminalBench (tbench) 比較レポートで特定された PTY drain スループット敗北(plain -38% / ansi -35% / scroll.proxy 1.6×)への構造改革。ingest パイプラインを再設計し、対 main +54〜59% を達成。

実測(実 tbench 3スイート、凍結 Ghostty nightly 1.3.2-main-+73534c468 比)

main this PR Ghostty 判定
render.throughput ansi 1.93M 2.96-2.98M lines/s 2.96M 勝利 (2/3 スイート)
render.throughput plain 2.11M 3.19-3.27M 3.40M -3.5〜-4.1%(-38%から改善)
scroll.proxy 47.2ms 30.3-31.4ms 29.5ms -3%台(1.6×から改善)
render.throughput cjk 1.83M 2.24-2.31M 2.20M 勝利 (3/3)
input.latency loaded 1.51ms 1.58-1.75ms 2.00ms 勝利 (3/3)
resource.cpu load 200.7% 146-149% 190.8% 勝利 (3/3)

plain 残差の物理: cooked tty (ONLCR) のカーネル天井 ≈409-418MB/s に対し Ghostty=98%・本 PR=93% 運転(raw 経路では read parity 515MB/s 達成済み)。

主要変更

  • 行融合バッチ (noa-vt/noa-grid): Stream が完結行(plain / edge-SGR styled)を検出し print_ascii_lines/print_sgr_ascii_lines へ一括投入。リージョン通過行はグリッド無接触でバイトスパンから直接封入(非記録リージョンはドロップ)。エンゲージ率 100%(実 fixture)
  • RingGrid: 全高スクロールを base-offset リングで O(1) 化(rotate/memmove 消滅)。部分 region・IL/DL・resize/reflow は canonicalize 経由で従来型
  • バイトスパン直行封入: DeferredRow::Span + Page::pack_span_row(pack_row と byte-identical、等価テスト付き)。span 支配バッチは owner inline flush へルーティング
  • reader 改良: refill bridge の spin_loop 化・チャンク境界跨ぎ・QoS 分離(reader=USER_INITIATED / pack=UTILITY)
  • 外科的削減: wide-prescan O(n)→O(2)・scroll_up(1) 特化・C0 直接 dispatch・print_str 再スキャン除去
  • メモリ: span アリーナ capacity 再利用・spare ページプール(cap 32)で flood 中の allocator churn を削減

検証

  • cargo test --workspace 2125 passed / 0 failedcargo clippy --workspace --all-targets 0 warning
  • バッチ vs 強制スカラーの差分テスト 26 本+span 封入等価 2 本+fuzz 42 seeds/grid(敵対的検証で発見した styled テンプレート継承バグは修正済み・回帰テスト付き)
  • 防衛軸: 負荷時 roundtrip p50 ~70-100µs(main 比 2× 改善)・flood 中 RSS 膨張ゼロ(load≈idle)・fresh idle RSS は main と同一(114.4 vs 114.6MB)
  • 計測ハーネス bench/probes/drain-staircase を常設(--tbench-faithful / --roundtrip / --mem-suite)

既知の残課題

  • plain/scroll の残 3-4% はカーネル天井効率の実装ギャップ(named lever なし、調整族 375-388 で飽和を確認)
  • flood したウィンドウの xzone dirty-page 高水位(~+15-20MB)はウィンドウ close まで滞留(構造リークなしは検分済み、tick_memory_trim 近傍に文書化)
  • cmd.overhead zsh の変動は環境要因(同一セッションの ghostty 1.3.1 自身が 1805-2340µs で同帯域)

https://claude.ai/code/session_01FcE6dYbpf9ATdVwMCRiGgy

simota added 26 commits July 18, 2026 07:09
…dges

An interior wide-pair hit's neighbor is also inside the segment and gets
overwritten by the unconditional fill regardless of what cleanup did to
it first, so the old any()-then-loop prescan re-read every cell in the
segment for no benefit beyond the two edges (whose neighbor can fall
outside the segment). Checking just row.cells[x] and row.cells[x+n-1]
collapses that read pass from O(n) to O(2), matching wish#4's hotpath
report (print_ascii_run 44% of owner apply time, wide-prescan called out
as a full extra read of the write target).
…d case

Every LF/IND at the bottom margin with the default full-screen scroll
region takes this exact shape (n==1, full_height, recorded) — the
overwhelmingly common case in any line-oriented flood. Split it into a
dedicated method that folds away what the general body always resolves
the same way for this combination: the single-row seal loop collapses
from a loop to one move, the !full_height point-shift and non-recorded
post-rotate clear are dead code for this shape, and the trailing
track_scroll_up call is skipped entirely since it's a guaranteed no-op
when recorded is true. Matches wish#4's hotpath report (scroll_up_region
~11-47% of owner apply time depending on workload).
feed_parser's ground-plain branch already knows state==Ground and
utf8_rem==0 (that's what in_ground_plain() asserts) by the time a byte
fails is_run_byte and isn't a fast-scannable CSI lead. For that byte —
almost always a C0 control in a line-oriented flood (LF terminating each
printed line) — Parser::advance's four dead prefix checks (DcsEscape,
DcsPassthrough+ESC, ApcEscape, ApcString+ESC: all state==Ground so all
false), the c1_control call (b can't be in 0x80..=0x9f, already ruled
out by is_run_byte), and the sink-closure indirection are all skippable:
in ground state every C0 byte (other than ESC, handled separately) maps
straight to Execute with no state change, and DEL is silently dropped —
both st_ground's own arms and the CAN/SUB "anywhere" transition (whose
forced state=Ground is a no-op here). Matches wish#4's hotpath report
(vt::feed_parser 7.6% + its closure 2.7% of owner apply time).
Every refill-bridge retry still pays a read() syscall to check for new
bytes; std::thread::yield_now() additionally pays a sched_yield() syscall
between checks (measured low-single-digit microseconds even when nothing
else is runnable), while std::hint::spin_loop() is a few cycles with no
syscall. Using the hint for the first 8 of REFILL_SPIN_MAX's 16 retries
and only falling back to yield_now() after that catches refill gaps that
close within tens of nanoseconds without paying for a syscall to wait for
them, before actually relinquishing the CPU for slower ones.

Gated on the same streaming || congested condition as the rest of the
bridge (unreachable for a non-streaming, non-congested read), so
interactive single-keystroke delivery still takes the same immediate
`break` as before — this only changes retries that were already inside
the flood-only bridge.

Measured (interleaved A/B, drain-staircase --mb 64 --reps 3, 4 rounds):
real S1 read+discard 452-456 -> 500-513 MB/s across plain/ansi/scroll,
consistent every round; S2/S3 unaffected (read-layer-only change).
In plain ground state, an LF/CR followed by at least one more complete
printable-ASCII line inside the chunk now dispatches the whole
complete-line span through a new Handler::print_ascii_lines bulk method
(default body replays per line via print_str/execute_c0, so existing
handlers are unaffected). Pure lookahead: anything but text+CRLF/LF
structure bails to the per-action paths with nothing committed.

Claude-Session: https://claude.ai/code/session_01FcE6dYbpf9ATdVwMCRiGgy
Terminal::print_ascii_lines hands complete-line spans to
Screen::apply_ascii_line_batch: the in-progress line replays through the
scalar primitives (one real index()), then every following row is built
from its byte span — rows that scroll past the region seal straight from
the span (or drop when the region records no scrollback), the grid
header rotation runs once per batch, and only the last region-height
rows are ever written to the live grid. Scroll bookkeeping (deferred
seal pushes, viewport pinning, tracked-point shifts, dirty/scroll_shift)
replays per sealed row in scalar order. Lines the screen state
disqualifies (cursor off the region bottom, margins, autowrap off,
non-ASCII charset, kitty placements) fall back to per-line replay.

Differential tests pin observable-state equality against a per-byte-fed
reference across chunkings, terminators, wrap boundaries, regions, alt
screen, BCE, 2027, pinned viewports, and seeded random token mixes.

Claude-Session: https://claude.ai/code/session_01FcE6dYbpf9ATdVwMCRiGgy
Terminal apply is the confirmed drain-throughput bottleneck
(.agents/reports/wish4-drain-profile.md), and within it,
Screen::scroll_up_region's Vec::rotate_left of the whole grid on every
line feed (~11-27% of owner apply time; see
.agents/reports/wish4-apply-hotpath.md §4②③) permutes every row
header even though only the retiring rows actually change.

Screen::grid is now a RingGrid (base-offset ring over the same
Vec<Row> storage) instead of a plain Vec<Row>. A full-height scroll
(top==0, bottom==rows-1 — the LF-driven hot path) seals/replaces the
retiring rows in place and advances the ring base by n: an O(1) index
bump instead of an O(rows) Vec::rotate_left + _platform_memmove.
Partial-region scrolls (DECSTBM), insert/delete lines, erase, and
resize/reflow stay correctness-first: they call RingGrid::canonicalize
first, which pays the same O(rows) rotate as before.

RingGrid keeps the same method names/signatures as Vec<Row> for single-
row access (Index/IndexMut/get/get_mut/len/iter, IntoIterator for
&/&mut) in logical (on-screen) order, so nearly every existing call
site — including every direct `screen.grid[y]` test poke across
noa-grid/noa-render/noa-app — compiles unchanged; only range-slicing
call sites (edit.rs, reflow.rs, one spot in text.rs) needed an explicit
canonicalize().

Full workspace test suite passes unmodified except one mechanical
`.grid.clone()` -> `.grid.iter().cloned().collect()` adaptation in
occupancy_watermark.rs, forced by the field's type change but not a
behavior change (base == 0 in that test, so the collected order is
identical either way). cargo clippy --workspace is clean for every
crate this change touches (noa-grid/noa-render/noa-app); the only
clippy warnings anywhere in the workspace are pre-existing and in
unrelated files (noa-vt/src/tests.rs, noa-ipc/tests/attach_tests.rs).
Stream's ground-scan fast path already knows (from scan_run's SWAR
boundary scan) that a whole text run is printable ASCII before handing it
to Handler::print_str, but print_str re-derives that per call — a
byte-classification pass over text this scan already proved ASCII, plus
loop bookkeeping around it — since it also serves callers that can't make
that guarantee (mixed-content chunks from the SIMD-validation and
valid_up_to fallback paths, which still call print_str as before).

Adds Handler::print_ascii_str (default: forwards to print_str, so it's a
no-op for implementations that don't override it) and wires the verified-
ASCII branch in feed_parser to it; Terminal's implementation collapses
straight to one Screen::print_ascii_run call under the same DEC Special
Graphics guard print_str already has. Same print bucket as the
print_ascii_run prescan commit (print_str was 13.1% of owner apply time
per wish#4's hotpath report, alongside print_ascii_run's 44.2%).
…turn)

Unrelated to Track C's grid/vt/pty work, but the workspace clippy gate
must be 0-warning and these two lints (a newer rustc's clippy started
flagging on unrelated tests.rs/attach_tests.rs code) already fired on
main before this branch touched anything — trivial mechanical fixes, no
behavior change.
# Conflicts:
#	crates/noa-grid/src/terminal/handler.rs
#	crates/noa-vt/src/handler.rs
#	crates/noa-vt/src/stream.rs
The track-a batch rotate and track-c scroll_up_one_full_recorded still
addressed the grid as a Vec slice. Full-height cases become an O(1)
advance_base (the retiring rows are sealed/overwritten around the bump,
so the ring's replace-before-advance contract holds); partial regions
canonicalize first, paying the same header rotate they always did.

Claude-Session: https://claude.ai/code/session_01FcE6dYbpf9ATdVwMCRiGgy
…ode)

Copies the wish#4 PREPARE-β staircase harness (S1 read / S2 parse /
S3 apply sb-off / S4 apply sb-on across proc/real/naive lenses) into the
converge branch so every cycle measures against the same in-tree tool.

Claude-Session: https://claude.ai/code/session_01FcE6dYbpf9ATdVwMCRiGgy
Extends the ground-state line-batch to SGR-dense floods, the tbench ansi
shape: lines of `sgr* text sgr* (CR)? LF` where each sgr is a whole
plain SGR (ESC [ params m, digits/;/: only). Stream's scanner admits the
shape and dispatches the span to the new Handler::print_sgr_ascii_lines;
the grid core (shared with the plain batch via a const-generic walker)
gives every batched row its own style template, applying lead/tail SGR
units to the pen in dispatch order per line.

One invariant keeps the batch machinery unchanged: every scroll a line
would perform — its LF, and any mid-text soft-wrap — must use the batch
entry's BCE blank. Lines whose post-lead (wrapping) or post-tail pen
would scroll a different blank roll the pen back and fall out to the
per-line replay before consuming a byte.

SGR->pen semantics move to Cursor::apply_sgr (single authority shared by
Terminal::set_attributes and the batch's speculation), and the plain-SGR
unit lexer/parser (scan_plain_sgr / parse_plain_sgr_unit) lives in
noa-vt's sgr module so scanner and consumers cannot drift.

Adds 7 vt tests (scanner shapes, split iterator, unit parse, default-body
replay) and 7 grid differential tests (tbench wrap, staircase palette,
BCE churn/constant-BCE, multi-unit edges, regions/alt, engagement pin),
plus styled tokens in the differential fuzz.

Claude-Session: https://claude.ai/code/session_01FcE6dYbpf9ATdVwMCRiGgy
SgrAsciiLines::next now mirrors the scanner's walk (plain-SGR units, one
SWAR text-run scan, terminator) instead of three scalar passes per line
(LF search, per-unit 'm' search, ESC search) — it was 29% of owner apply
time on the ansi flood. The styled batch adds a per-call 8-entry memo
keyed by (pen, lead ++ tail bytes): SGR decoding is pure, and floods
cycle a handful of edge shapes, so steady-state lines byte-compare
instead of lex+parse+apply (18% of owner). ansi real S3 326->416 MB/s,
proc S4 480->661 MB/s on the 140x40 CRLF staircase.

Claude-Session: https://claude.ai/code/session_01FcE6dYbpf9ATdVwMCRiGgy
…oundary bridge, QoS split

Three scheduling fixes for the flood drain, found by real-lens profiling
(the owner and pack workers sat half-idle while the reader burned 32% of
its time in swtch_pri):

- The refill bridge's yield_now fallback becomes a spin_loop hint for
  all retries: with pipeline threads runnable, sched_yield deschedules
  the reader for a real quantum, the ~1KiB kernel tty queue brims, and
  the producer blocks (140x40 CRLF staircase real S4 plain 292->324).
- The bridge now also covers the empty-buffer moment right after a
  mid-flood chunk handoff (was_streaming carried across chunks), which
  used to pay a poll-park + wake per gap (real S1 504->540).
- macOS QoS: the reader declares USER_INTERACTIVE (its wake latency is
  the drain rate) — real S4 plain 324->360.

Claude-Session: https://claude.ai/code/session_01FcE6dYbpf9ATdVwMCRiGgy
The wish#4 sealing-backpressure wall: under a dense flood every sealed
row was materialized as a Cell row (pool/blank acquisition + ~cols x 24B
cell writes on the owner), re-read by the pack workers (~cols x 24B),
packed, and its carcass round-tripped back through the pool — starving
the pool (row_with_blank was 24% of owner busy time) and saturating the
memory system three threads wide.

The deferred tier now holds an enum: Cell rows from the scalar seal
path, or SpanRow — the line batch's text bytes (copied into a shared
per-batch arena), first column, per-line style template, and BCE blank.
Span rows fully determine their row, so they pack byte-identically to
the materialized row (Page::pack_span_row: at most two style interns
per row, cells written straight from the bytes) and materialize on
demand for reads; no Cell row ever exists for them. Pack workers also
declare UTILITY QoS so batch packing never displaces the reader/io
threads from performance cores.

Equivalence is pinned by two new scrollback tests (pack-identity across
blank/template styling, trims, lead columns; deferred-pipeline history
identity vs the Cell-row path) on top of the 26 line-batch differential
tests. 140x40 CRLF staircase real S4: plain 360 -> 450 MB/s, ansi 385
-> 451 MB/s — past the read-path ceiling gap the tournament flagged.

Claude-Session: https://claude.ai/code/session_01FcE6dYbpf9ATdVwMCRiGgy
… probe

--tbench-faithful: the real tbench fixture through one cooked (real
ONLCR) warm pty session, BEL-delimited sequential runs, per-run MB/s +
median — the cycle-2 authoritative oracle (--raw-tty isolates the ONLCR
cost). --roundtrip: interactive CPR reply latency riding an output
burst's tail, p50/p99, the defense-axis probe.

Claude-Session: https://claude.ai/code/session_01FcE6dYbpf9ATdVwMCRiGgy
… pen

Cycle-1 CONFIRMED fidelity bug: inside a styled batch, a line with no
edge SGRs reused the previous line's post-lead template even when that
line's *tail* SGRs had just moved the pen (`ESC[41m AAA ESC[0m` then a
plain `BBB` painted BBB red), corrupting both the live grid and the
scrollback it seals into (alt screen same root). Lines without edge
SGRs now rebuild the template from the current pen.

Adopts the skeptics' reproductions as permanent integration tests
(styled_batch_bug, ring_interleave_diff 40-seed × 4-grid interleave
fuzz, wide_remnant_diff print_ascii_run cross-check), widens the
line-batch differential fuzz from 3 to 42 seeds per grid, and adds two
tail-pen-shift fuzz tokens targeting exactly this class. Also clears
the clippy --all-targets stragglers (type_complexity via a test type
alias, manual_range_patterns, int_plus_one).

Claude-Session: https://claude.ai/code/session_01FcE6dYbpf9ATdVwMCRiGgy
tbench-faithful decomposition (cooked ONLCR + real fixture + warm
session, 80x24) put the read ceiling itself at ~415 MB/s (S1 413 / S2
420 / S3 416) with S4 at 341 — the whole residue is S4-specific, and
profiling showed nobody saturated: the worker handoff's publish/collect
bookkeeping and wake-ups steal from the pty quantum cycle the drain
rate lives on (3 workers 375, 1 worker 395, no workers 402 MB/s).

The batch boundary now routes on pending composition: span-dominated
pending (cell rows <= 1/16 — the line-batch steady state seals only
~one screenful of Cell rows per chunk) packs inline on the owner at a
4x-wider cadence (pack_span_row is ~an order of magnitude cheaper per
row, and the quarter-limit over_limit guard still bounds the transient
exactly as before); cell-dominated batches keep the worker pool and its
1MiB-raw batch economics (wide-grid cell packing is what the pool
exists to absorb). Also memoizes the per-push batch-rows derivation on
(cols, limit).

The two-path history-identity test now allows page-split slack in the
byte accounting (content identity unchanged): the paths batch at
different cadences by design. tbench-faithful 341.5 -> ~380 MB/s
median; line-batch engage rate on the real fixture measured 100.00%
(1,197,036 batch lines vs 12 scalar fallbacks — wrapped 120B lines all
batch).

Claude-Session: https://claude.ai/code/session_01FcE6dYbpf9ATdVwMCRiGgy
…nobs

One warm Terminal + one cooked pty absorbs N repetitions of the real
plain/ansi/cjk fixture rotation, printing RSS at every fixture boundary
— reproduces tbench's suite-over-suite resource.mem growth in-harness
(DS_NO_TRIM models the app's 30s-quiescence trim never firing during
back-to-back suites; DS_RELIEF models the mid-burst free-page relief).

Claude-Session: https://claude.ai/code/session_01FcE6dYbpf9ATdVwMCRiGgy
…-burst relief

The tbench decision run showed resource.mem climbing 145.7 -> 164.2 ->
186.3 MB across three back-to-back suites (old build: flat 136.9).
Reproduced in-harness (--mem-suite, DS_NO_TRIM): with the 30s
memory-trim quiescence re-armed by every redraw, a multi-minute suite
never trims, and each flood's transient high-water (worker-lagged Cell
pending on the cjk fixture, freed page/arena churn under the xzone
allocator's quarantine) joins a monotonically growing dirty-page union
(+20MB spikes observed).

Three-part fix:
- The inline span flush restores its byte arena (cleared, capacity
  kept) instead of dropping it — one ~quarter-limit allocation per
  flush gone.
- The evicted-page spare becomes a bounded pool (SPARE_PAGE_CAP = 32,
  ~2MiB, dropped by trim_memory): the inline flush packs then evicts
  page-batches back to back, so the old 1-deep spare thrashed ~20
  64KiB arena allocations + frees per flush.
- The app returns already-free heap pages every 10s *during* a burst
  (malloc_zone_pressure_relief only — no scrollback settle, no pool
  drop, so none of the churn that made the old 8s full trim
  counterproductive; the 30s full trim is unchanged).

Harness model: 3 suites x (plain+ansi+cjk) x 10 runs, no-trim cadence:
before 46.6 -> 67.1 MB spike retained; with relief 49-50.5 MB flat
(±1MB). Faithful throughput unchanged (plain 382.9 / ansi 372.0).

Claude-Session: https://claude.ai/code/session_01FcE6dYbpf9ATdVwMCRiGgy
The unconditional USER_INTERACTIVE pin regressed real cmd.overhead from
~1284 to 1830-2050us (ship-gate finding): the top QoS tier shifts Apple
Silicon's P/E placement against a just-forked shell's init, taxing every
spawn to protect a drain rate that does not need it — USER_INITIATED
still outranks the UTILITY pack workers (the only displacement the
reader must win) and measures identically on the drain itself
(tbench-faithful S1 411.7 / S4 380.4 MB/s at either tier; post-change
plain 379.7 / ansi 371.9 vs 382.9 / 372.0 before, within noise).

Claude-Session: https://claude.ai/code/session_01FcE6dYbpf9ATdVwMCRiGgy
malloc_zone_pressure_relief reports 0 bytes relieved under the xzone
allocator (macOS 26 Apple Silicon — crate::memory's own measured
caveat), so the 10s mid-burst relief added in 81031d4 was dead code on
exactly the machines it targeted. Removed; the churn-reduction side of
that change (span-arena capacity reuse, bounded spare-page pool) stays.
tick_memory_trim now documents the observable that motivated the timer:
RSS sampled mid-suite under back-to-back floods reads a bounded
transient high-water (xzone dirty-page retention; per-page style and
grapheme tables die with evicted pages, arena/pools are hard-capped),
settled by the post-quiescence trim — not a leak.

Claude-Session: https://claude.ai/code/session_01FcE6dYbpf9ATdVwMCRiGgy

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 75d6813508

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +141 to +144
impl Handler for NoOp {
fn print(&mut self, _c: char) {}
fn print_str(&mut self, _s: &str) {}
fn execute_c0(&mut self, _b: u8) {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Override line-batch callbacks in the no-op probe

When the drain-staircase probe runs S2 on the plain/ANSI flood workloads, Stream now dispatches complete line spans through Handler::print_ascii_lines / print_sgr_ascii_lines; because this no-op handler does not override those new default methods, S2 spends time splitting/replaying every line and parsing edge SGRs before reaching no-op callbacks. That makes the advertised S2 parse(noop) stage measure default handler work in addition to parser dispatch, underreporting the parser-only ceiling used to diagnose the drain pipeline.

Useful? React with 👍 / 👎.

@simota
simota merged commit 1b1ebef into main Jul 19, 2026
1 check passed
@simota
simota deleted the wish4/converge branch July 19, 2026 09:30
@simota
simota restored the wish4/converge branch July 19, 2026 09:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant