pty: park the Linux PTY reader in poll(2) without spinning #421

Merged
natsukium merged 3 commits from pty/linux-reader-no-spin into main 2026-09-25 08:46:58 +09:00
Owner

On Linux the PTY reader stops spinning on EAGAIN and parks in poll(2) right away. macOS keeps the 50 µs spin, which was sized for its ~1 KiB PTY lockstep. Linux queues ~68 KiB, so there the spin cost reader CPU and did not change the frame rate.

Closes #399. The two parser costs from its profile are split out to #416 (SGR pen re-interning, 21% of the parse thread under DOOM-fire) and #417 (Params::push_run, 10.7%).

Refs #400. That issue proposed coalescing the ~13-byte reads on vtebench's scrolling rows. It is rejected, and docs/explanation/implementation.md records why: when output sits unread in the kernel, the producer's own write path gets slower. A bare drain pausing 20-50 µs between reads took scrolling at 82x212 from ~160 to ~255 ms per sample (five interleaved pairs). A felis build that waited up to 50 µs while the parser was busy cut reader CPU by 93% but ran the harness's scrolling rows up to twice as slow.

Measurements

Linux, i5-12400F, niri 60 Hz, NVIDIA through the pinned ICD (checked in the client's mapped libraries). Release builds of main 6f3931a1 and of this change, run interleaved. The host is also the CI runner, so the 1-min load average is given with each figure.

workload load main this branch
DOOM-fire fps, 12 s at 93x212, 5 undisturbed pairs 2.5-7.6 897 / 914 / 902 / 945 / 981 (mean 928) 910 / 896 / 938 / 959 / 947 (mean 930)
DOOM-fire reader thread, user + sys per 12 s same 1.7-2.5 + 3.2-3.6 s 0.3-0.4 + 3.6-3.7 s
DOOM-fire suite, 20 s at 212x82, fps (daemon CPU) 3.7-10.8 1029 / 947 / 951 (28.8-29.1 s) 991 / 940 / 917 (26.7-26.9 s)
vtebench scrolling median, 93x212, 3 pairs 2.7-3.5 130.5 / 123 / 133 ms 126 / 113 / 108.5 ms
vtebench scrolling_top_region median, 3 pairs 3.5-4.0 124.5 / 125 / 121 ms 134.5 / 112 / 121.5 ms
cat suite ascii / unicode / csi, 150 MB 6.3-10.1 1.56 / 0.85 / 1.05 s 1.53 / 0.77 / 0.90 s
profile_echo_latency p50 / p99, 3 pairs 2.1-4.1 5.41 / 6.44-6.48 ms 5.38-5.41 / 6.44-6.58 ms

The spin never touched the scrolling rows' reader cost: those ~13-byte reads always find data waiting.

Review

pi (sol) round 1: PASS, with one non-blocking finding. It suggested dropping the doc comment on the Linux READ_SPIN_WINDOW because the explanation doc already carries the rationale. I kept the comment: it is the why-not for someone who would otherwise bring the macOS spin back to Linux.

On Linux the PTY reader stops spinning on `EAGAIN` and parks in `poll(2)` right away. macOS keeps the 50 µs spin, which was sized for its ~1 KiB PTY lockstep. Linux queues ~68 KiB, so there the spin cost reader CPU and did not change the frame rate. Closes #399. The two parser costs from its profile are split out to #416 (SGR pen re-interning, 21% of the parse thread under DOOM-fire) and #417 (`Params::push_run`, 10.7%). Refs #400. That issue proposed coalescing the ~13-byte reads on vtebench's scrolling rows. It is rejected, and `docs/explanation/implementation.md` records why: when output sits unread in the kernel, the producer's own write path gets slower. A bare drain pausing 20-50 µs between reads took `scrolling` at 82x212 from ~160 to ~255 ms per sample (five interleaved pairs). A felis build that waited up to 50 µs while the parser was busy cut reader CPU by 93% but ran the harness's scrolling rows up to twice as slow. ## Measurements Linux, i5-12400F, niri 60 Hz, NVIDIA through the pinned ICD (checked in the client's mapped libraries). Release builds of main `6f3931a1` and of this change, run interleaved. The host is also the CI runner, so the 1-min load average is given with each figure. | workload | load | main | this branch | |---|---|---|---| | DOOM-fire fps, 12 s at 93x212, 5 undisturbed pairs | 2.5-7.6 | 897 / 914 / 902 / 945 / 981 (mean 928) | 910 / 896 / 938 / 959 / 947 (mean 930) | | DOOM-fire reader thread, user + sys per 12 s | same | 1.7-2.5 + 3.2-3.6 s | 0.3-0.4 + 3.6-3.7 s | | DOOM-fire suite, 20 s at 212x82, fps (daemon CPU) | 3.7-10.8 | 1029 / 947 / 951 (28.8-29.1 s) | 991 / 940 / 917 (26.7-26.9 s) | | vtebench `scrolling` median, 93x212, 3 pairs | 2.7-3.5 | 130.5 / 123 / 133 ms | 126 / 113 / 108.5 ms | | vtebench `scrolling_top_region` median, 3 pairs | 3.5-4.0 | 124.5 / 125 / 121 ms | 134.5 / 112 / 121.5 ms | | `cat` suite ascii / unicode / csi, 150 MB | 6.3-10.1 | 1.56 / 0.85 / 1.05 s | 1.53 / 0.77 / 0.90 s | | `profile_echo_latency` p50 / p99, 3 pairs | 2.1-4.1 | 5.41 / 6.44-6.48 ms | 5.38-5.41 / 6.44-6.58 ms | The spin never touched the scrolling rows' reader cost: those ~13-byte reads always find data waiting. ## Review pi (sol) round 1: PASS, with one non-blocking finding. It suggested dropping the doc comment on the Linux `READ_SPIN_WINDOW` because the explanation doc already carries the rationale. I kept the comment: it is the why-not for someone who would otherwise bring the macOS spin back to Linux.
The 50 µs non-blocking retry window in MasterReader::read was sized
against the macOS PTY, which drains a bulk write in a ~1 KiB lockstep:
there each read is what lets the writer refill, and spinning through the
2-4 µs gaps took DOOM-fire from 628 to 666 fps. Linux queues ~68 KiB
(64 KiB of flip buffers plus the 4 KiB line-discipline read buffer), so
its writer is not paced by the reader chunk by chunk, and the spin only
bought reader CPU. Linux now parks in poll(2) at the first EAGAIN; macOS
keeps the spin.

Measured on Linux (i5-12400F, niri 60 Hz, NVIDIA; this host is also the
CI runner, load average given per figure), release builds of main
6f3931a1 and of this change, interleaved:

- DOOM-fire, 12 s at 93x212, five undisturbed pairs (load 2.5-7.6):
  fps 897/914/902/945/981 before, 910/896/938/959/947 after (means 928
  and 930). Reader user time 1.7-2.5 s -> 0.3-0.4 s per 12 s, sys
  +0.3-0.5 s for the extra poll(2) parks; net reader CPU 5.3-5.9 s ->
  4.0-4.1 s.
- DOOM-fire suite (tools/bench, 20 s at 212x82), three pairs at load
  3.7-10.8: fps 1029/947/951 before, 991/940/917 after; daemon CPU
  28.8-29.1 s -> 26.7-26.9 s.
- vtebench scrolling / scrolling_top_region, 10 s at 93x212, three
  pairs each (load 2.7-4.0): medians 130.5/123/133 vs 126/113/108.5 ms
  and 124.5/125/121 vs 134.5/112/121.5 ms. The reader's cost there is
  ~13-byte reads, which the spin never touched.
- cat suite (ascii/unicode/csi, 150 MB): 1.56/0.85/1.05 s before,
  1.53/0.77/0.90 s after in the undisturbed pair.
- profile_echo_latency (keystroke to shadow grid), three pairs: p50
  5.41/5.41/5.41 ms vs 5.41/5.41/5.38 ms, p99 6.44-6.48 vs 6.44-6.58 ms.

The explanation doc also records why the ~13-byte reads stay: a reader
that lets a trickling producer's output pile up in the kernel slows that
producer's own write path. A bare consumer pausing 20-50 µs between
reads (sleep or busy-wait) took vtebench scrolling at 82x212 from ~160
to ~255 ms per sample, and a felis reader that waited up to 50 µs while
the parser was busy cut its CPU by 93% but ran the bench harness's
scrolling rows up to twice as slow.

Assisted-by: Claude Code
skills: map where the Linux PTY reader's CPU goes
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 4m28s
pr / nix flake check (pull_request) Successful in 1m52s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 4m9s
windows / detect relevant changes (pull_request) Successful in 3s
windows / cargo clippy (Windows cross) (pull_request) Successful in 1m19s
darwin / detect relevant changes (pull_request) Successful in 3s
darwin / darwin docs-only no-op (pull_request) Has been skipped
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 1m37s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m59s
pr / wire schema is compatible with the base (pull_request) Successful in 56s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo nextest (Windows) (pull_request) Successful in 3m58s
windows / frontend smoke (Windows) (pull_request) Successful in 1m46s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
1e745e90fd
The perf-trace cost map described the reader's 50 µs spin as the
topology everywhere and its rejected alternatives without saying they
were measured on macOS. With the spin now macOS-only, a Linux pass
starting from the map would otherwise re-derive two things: that the
reader, not the parser, dominates daemon CPU on vtebench's scrolling
rows (~13-byte reads, ~1.2 M/s), and that batching those reads is a
known loss, because output left unread slows the producer's own kernel
write path. The figures are the ones behind the Linux reader change.

Assisted-by: Claude Code
Merge remote-tracking branch 'origin/main' into pty/linux-reader-no-spin
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 5m36s
pr / nix flake check (pull_request) Successful in 1m24s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 3s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / frontend smoke (Windows) (pull_request) Successful in 2m5s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
darwin / detect relevant changes (pull_request) Successful in 5s
darwin / darwin docs-only no-op (pull_request) Has been skipped
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 2m14s
pr / cargo build / clippy / test / deny (pull_request) Successful in 3m57s
pr / wire schema is compatible with the base (pull_request) Successful in 40s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 6m51s
windows / cargo nextest (Windows) (pull_request) Successful in 4m14s
windows / cargo clippy (Windows cross) (pull_request) Successful in 35s
57a4c12bf7
natsukium deleted branch pty/linux-reader-no-spin 2026-09-25 08:46:58 +09:00
Commenting is not possible because the repository is archived.
No description provided.