perf: PTY drain ingest overhaul — line-batch fusion, ring grid, byte-span sealing#29
Conversation
…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
There was a problem hiding this comment.
💡 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".
| impl Handler for NoOp { | ||
| fn print(&mut self, _c: char) {} | ||
| fn print_str(&mut self, _s: &str) {} | ||
| fn execute_c0(&mut self, _b: u8) {} |
There was a problem hiding this comment.
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 👍 / 👎.
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 比)
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)DeferredRow::Span+Page::pack_span_row(pack_row と byte-identical、等価テスト付き)。span 支配バッチは owner inline flush へルーティング検証
cargo test --workspace2125 passed / 0 failed・cargo clippy --workspace --all-targets0 warningbench/probes/drain-staircaseを常設(--tbench-faithful / --roundtrip / --mem-suite)既知の残課題
tick_memory_trim近傍に文書化)https://claude.ai/code/session_01FcE6dYbpf9ATdVwMCRiGgy