Skip to content

perf 2/5: bounded, paced PTY ingest — frames during floods, flat memory - #62

Merged
EDToaster merged 11 commits into
mainfrom
perf/io
Oct 22, 2026
Merged

EDToaster merged 11 commits into
mainfrom
perf/io

Conversation

@EDToaster

@EDToaster EDToaster commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Stack 2/5. Based on #61. It fixes how PTY output reaches the main thread, so toastty keeps drawing while a program floods it and its memory stays flat.

Problem

The mio reader turned every read (usually a few hundred bytes) into its own Vec and its own winit user event, over an unbounded channel. Under cat bigfile:

  • No frame was drawn until the flood ended. winit hands out every queued user event before RedrawRequested.
  • Memory grew without bound: peak RSS of 230–680 MB while the backlog sat in the queue.
  • Fixed costs ran on every event, about 115k times per 64 MB: a tcgetpgrp ioctl, a readlink /proc/<pid>/cwd, selection revalidation and so on.

Change

  • Bounded shared buffer (toastty-io):
    • The reader appends to a 256 KiB buffer it shares with a PtyReader on the main thread. Past the cap it stops reading, so the kernel PTY buffer fills and the producer blocks (back-pressure).
    • The reader posts a payload-less PtyReady only when no wake is already queued, so there is never more than one in flight.
    • The main thread swaps the whole buffer out, so there's no copy or allocation in steady state.
    • The close is a flag kept under the same lock, so it is only seen once every byte has been parsed.
  • Paced parsing (crates/toastty/src/ingest.rs, handle_pty_ready):
    • Output is parsed in 16 KiB slices. Small writes are still parsed and drawn at once.
    • Under sustained output the handler stops once a frame is due (16.7 ms) and returns the new ControlSignal::Yield. The runner redraws, then resumes parsing from about_to_wait.
    • Slow frames get at least twice their render time in parsing, so rendering can use at most a third of the main thread.
    • Mode 2026: a frame is never cut inside a synchronized update. Once a frame is due, parsing stops right after the next ESU. A long update returns to the loop every 33 ms (ControlSignal::Defer, which requests no redraw) so input stays live. The 1 s watchdog is unchanged.
  • Redraw cap: a pass that drains the buffer asks for its redraw only once a frame is due (Ingest::redraw_delay). It's zero after a quiet spell, so echoes still draw at once. Without this, floods drew up to 360 FPS once perf 3/5: parser + grid hot path — 2–7× parse, lazy scrollback (idle RSS 101→60 MB) #63's faster parser outran the reader. With perf 3/5: parser + grid hot path — 2–7× parse, lazy scrollback (idle RSS 101→60 MB) #63 on top, the cap cut flood CPU by 15–26% at the same throughput (ASCII 1.14 → 0.97 CPU s, seq 1.60 → 1.18), with key latency unchanged.
  • The foreground-CWD probe now runs only for slices that open an APC. RGP r;path= registration is the only reader of that CWD.
  • The design is written up as a dated section in docs/decisions/pty-event-loop.md.

Results (bench/run.py, Xvfb, median of 3; the machine was shared with other jobs)

scenario before (#61) after
cat 64 MB ASCII: FPS while streaming 0 59
cat 64 MB ASCII: peak RSS 228 MB 104 MB
seq 3M lines: MB/s / CPU s / peak RSS 13.3 / 2.68 / 683 MB 20.3 / 2.03 / 103 MB
cat 32 MB SGR: FPS / peak RSS 0 / 200 MB 60 / 104 MB
dense_cells 128×42 / 256×73: FPS 0 / 0 60 / 55
output→frame during a flood ∞ (no frame until the end) p50 17 ms, p99 19 ms (one frame)
key→frame p50 / p99 0.63 / 1.50 ms 0.61 / 1.46 ms
wheel scroll 2560×1400, frame p99 2.91 ms 1.58 ms
idle CPU blink / steady 0.2 % / 0.0 % 0.3 % / 0.0 % (within noise)

Throughput now pays for about 60 real frames a second. In interleaved runs ASCII and SGR break even within noise. Unicode drops to about 0.75×, because each CJK/emoji frame costs about 9 ms to shape and render and the flood now draws about 50 of them a second. Stream B (#63) speeds up the parser and stream C (#64) the per-frame cost, which wins this back.

Visual evidence

Two screenshots 0.5 s apart while seq 1 3000000 streams (bottom 200 px of a 1280×800 window).

Before: the screen stays blank for the whole flood.
before: no frames while seq streams

After: live output, then the end of the run.
after: frames keep coming during the flood

I also checked by hand: Ctrl-C during a big cat gets the prompt back within 0.3 s, \e[6n gets its reply, OSC 2 sets the title, and exit closes the window.

Review

An independent reviewer found a permanent stall: a pass that was already over its frame budget when it started returned Continue without taking any bytes, so the reader's wake stayed armed and output froze. They reproduced it with an injected 40 ms deschedule.

  • It's fixed in 00a6a35. The decision logic is now the pure functions Ingest::step/outcome, a pass never stops before its first slice, and every stop schedules a resume. Regression tests cover it.
  • The re-review checked the fix with the same injection, which no longer stalls.
  • The re-review also found a lost 1 s watchdog wake after Defer inside a synchronized update. That's fixed in d3d9d8b, with a test.

Risks

  • The resume relies on about_to_wait running after the iteration's redraws. I confirmed that in the winit 0.30.13 source for X11, Wayland and macOS, but only X11 has been run. A comment warns that adding Wayland's pre_present_notify() would break this ordering.
  • The flood frame rate is fixed at 60 Hz, not the display's refresh rate.

Tests

New io tests use a socketpair and a counting sink. They cover: one wake per take, the writer blocking at the cap, close only after the last byte, and a dropped reader releasing a blocked thread. The ingest decision functions and the window scheduling for Yield/Defer have unit tests, and the integration and end-to-end tests were ported. cargo test --workspace passes, and clippy adds no warnings beyond the base.

The reader posted every 4 KiB read as its own UserEvent::PtyBytes(Vec)
through winit's unbounded proxy channel. Under a flood it ran ahead of
the parser by the whole output (peak RSS 400-650 MB) and the main
thread handled ~16k events per 64 MB, each with a fresh Vec.

Now the reader appends to a buffer shared with the main thread's
PtyReader and posts a payload-less PtyReady only when none is in
flight. Past BUFFER_CAP (256 KiB) it stops reading, so the kernel PTY
buffer fills and the child blocks. The main thread swaps the whole
buffer out (no copy, no steady-state allocation) and hands it to the
parser via peek/consume. The close is a flag in the same state, so it
is reported only once every byte read before it has been consumed;
Event::PtyBytes/PtyClosed become Event::PtyReady.

A batch that ends mid synchronized update now always asks for a wake
at the BSU watchdog deadline (previously only when the BSU began in
that batch; otherwise it requested a redraw render_term would skip).
Parsing everything the reader had buffered before each frame drew
nothing until a flood ended: winit hands out every queued user event
before RedrawRequested, and the handler never ran out of bytes.

handle_pty_ready now parses in 16 KiB slices. Output that drains
before a frame interval passes is drawn at once as before. When output
keeps coming it stops once a frame is due (16.7 ms after the last frame
started, and after parsing at least as long as that frame took) and
answers the new ControlSignal::Yield. The window runner redraws, drops
reader wakes until about_to_wait (which runs after the iteration's
redraws on X11, Wayland and macOS) and there delivers PtyReady again:
one parse slice plus one frame per loop iteration. A check at the top
of the handler covers output arriving about as fast as it parses.

Mode 2026: never yields while rendering is paused; once a frame is due
mid update, parses only up to the next ESU so the frame lands between
updates even when ESU and the next BSU are back to back.

The pacing policy lives in toastty::ingest with unit tests; the design
is recorded in docs/decisions/pty-event-loop.md.
handle_pty_bytes ran tcgetpgrp + readlink /proc/<pid>/cwd (and built a
String) for every PTY batch, ~8% of flood CPU. The CWD's only reader is
RGP's r;path= registration, which is an APC, so probe only when the
slice contains ESC _ (tracking an ESC that ends the previous slice).
The probe still runs before the slice is parsed, so a relative path is
resolved against the CWD of the process that is emitting it.
With the parse floor equal to the last frame's render time, a flood of
frames that each cost ~10 ms (CJK text, whose glyphs are new to the
atlas) spent half the main thread rendering: unicode throughput fell to
~0.45x of no-frames. Parse at least twice the render time (capped at
33 ms), so rendering takes at most a third; frames up to ~5 ms keep the
60 Hz cadence.
If a frame was already due when the loop first checked (the thread was
descheduled for >= min_parse after the pass started), it stopped
without peeking, and the 'nothing parsed' check answered Continue
before the yield could. Nothing had taken the reader's buffer, so its
wake stayed armed and it never posted PtyReady again: output froze for
good (reviewer repro: one 40 ms stall froze `seq 1 3000000`).

The loop's decisions move into ingest::Ingest::step and ingest::outcome,
pure and unit-tested: step never stops before a slice is parsed (that
slice also takes the buffer and re-arms the wake), and outcome answers
any stop with Yield whatever was parsed. Also time the slice after
peek() so a buffer swap isn't counted as parse time.
While mode 2026 holds frames back the parse loop never yields (the
frame would be skipped), so a flood inside one long BSU kept input,
resize and close waiting for up to the 1 s watchdog. After 33 ms of
parsing in that state, stop with the new ControlSignal::Defer: like
Yield (the runner drops reader wakes and resumes from about_to_wait)
but without requesting a redraw.
…share test glue

- App::event documents ControlSignal::Yield and Defer.
- Runner::about_to_wait notes that Wayland pre_present_notify() would
  defer RedrawRequested past the resume and make it spin under Poll.
- PtyReader::has_unconsumed is test-only now.
- ChannelSink/recv_batch move to a hidden toastty_io::test_support
  module shared by the toastty-io and end_to_end tests.
- pty-event-loop.md: Defer and the never-stop-before-a-slice invariant.
@EDToaster
EDToaster added this pull request to stack #66 October 22, 2026 18:08
Base automatically changed from perf-experiment to main October 22, 2026 18:09
# Conflicts:
#	crates/toastty/src/main.rs
@EDToaster
EDToaster merged commit 04acc33 into main Oct 22, 2026
1 of 2 checks passed
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