daemon: Linux vtebench scrolling rows trail the peers and vary 25-50% between rounds #400
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#400
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?
On Linux felis trails on every vtebench scrolling row except
scrolling_fullscreenand varies 25-50% between rounds (docs/reference/benchmarks.md, felis9d1d842f):On macOS the same rows take felis 9.5-10.1 ms and it leads them. Every terminal on Linux sits above 100 ms, so the gap is likely not the parser: something between the PTY and the daemon's read loop sets the pace on Linux.
Plan:
powersavegovernor noted in the run conditions, and with the load average recorded.Done when felis's median on these rows is at or below the Linux peers', or the cause is shown to be outside felis and recorded.
Linux measurement (kilimanjaro, i5-12400F, niri 60 Hz, NVIDIA via a pinned ICD, see #398). felis
mainef96bb46release. Load average (1 min) is given with each figure; CI ran on the host throughout, so wall-clock medians swing by tens of percent between runs, while read counts and CPU splits are stable.What paces the scrolling rows on Linux: the producer's own write path
vtebenchitself spends 100% of the pass in the kernel. Wrapped in GNUtimeinside felis, it showssys10.07 s of 10.12 s wall, 5-78 voluntary context switches,user0.00 s. It never blocks on a full PTY. It is CPU-bound inwrite(2), where the slave's line discipline expands every\n(ONLCR) and pushes each piece throughpty_write→ flip buffer →flush_to_ldisc.ptyfloor.py, 35x106 winsize) gets 120-130 ms median onscrollingfor the same 1.6 MB sample (load 3.8-4.5), with the writer'ssysagain equal to wall. That is the Linux floor for this payload, and it explains why every Linux terminal sits above 100 ms while macOS runs the same row in 10 ms.How the daemon reads (the question in the plan)
scrolling12 s, 188x93scrolling10 s, 141x53scrolling10 s, 282x108(
/proc/<pid>/task/<tid>/iosyscr/rchar, read at the pass's start and end markers.)read(2)returns the few lines the writer has pushed since the previous one, about 1.2 M syscalls/s.EAGAINspin inMasterReader::read(crates/felis-pty/src/unix.rs:67-104) is not what drives the count here. With the spin set to 0 the pass still makes 13-15 M reads of 12-16 bytes. On DOOM-fire the spin does cost reader user time (#399).Experiment: coalesce small reads (local, env-gated patch, not committed)
After a read shorter than 2048 bytes, and only when the parser was not parked,
pump_reads(crates/felis-pty/src/lib.rs:634) sleeps 20 µs before the next read:On
scrolling, reader CPU drops by 92%, and the median drops by 10-20% in the manual pairs. In the harness pair the rows are flat within noise, and daemon CPU for the whole vtebench pass drops by 33%.The back-off as written is not shippable. Under DOOM-fire it starves the parser: the frame rate fell from 933 / 795 fps to 400 / 666 fps with a 2048-byte gate, and 929 → 601 fps with a 256-byte gate in one of two pairs. One PTY read on Linux never returns more than about 4 KiB (the
N_TTY_BUF_SIZEread buffer), so every sleep caps ingest.Proposed fix
Coalesce reads in
pump_readsonly while the parser still has a backlog. The reader backs off after a small read only whenfillingalready holds at leastMIN_SWAP(8 KiB,crates/felis-pty/src/lib.rs:726) that the parser has not taken yet, or it waits onpoll(2)with a short timeout instead ofsleep. Keystroke echo is unaffected, because an idle parser is parked and the back-off does not apply. Expected gain on Linux: reader CPU down about 90% on line-heavy output (7-10 s per 10-12 s pass), daemon CPU on vtebench down about 30%, scrolling rows down 0-20%. DOOM-fire frame rate must stay unchanged; any patch needs a DOOM-fire A/B on a quiet host. Separately, dropping or shortening the 50 µs spin on Linux saves 1.4-1.7 s of reader user time per 12 s of DOOM-fire (#399).On the done condition: the >100 ms level on Linux is set outside felis, by the kernel's PTY output processing in the producer's context (the no-op drain floor is 120-130 ms). What felis controls is the syscall rate of its reader thread; the fix above cuts that rate by more than 90%.
I tried the proposed fix, coalescing reads while the parser has a backlog. It does not hold up: it trades the reader's CPU for the producer's throughput. #421 lands only the spin removal (#399) and records the rejection in
docs/explanation/implementation.md.Linux, i5-12400F, release builds, interleaved runs, 1-min load 2-6 unless noted:
ptyfloor.py, a bare Python drain of the PTY master, on vtebenchscrollingat 82x212:scrollingat 93x212: 13 → 650-920 bytes per read, 7.8 s → 0.55 s of reader CPU per 10 s.scrolling_top_regionandscrolling_top_small_regionmedians 212 / 231 ms vs 105 / 107 ms on main, with samples split between ~80-100 ms and ~220-245 ms. A second run put every scrolling row at 215-235 ms.So what felis controls on these rows is how promptly the reader drains, and draining promptly is what keeps the producer fast. The scrolling rows' CPU is the price of their throughput.
I have not sized the harness gap to foot (the published 144-150 vs 118-125 ms). In #421's interleaved manual pairs, the spin removal is neutral or better on these rows:
scrolling130.5 / 123 / 133 → 126 / 113 / 108.5 ms. Leaving this open for that call.Measured at
64a5eb24(nix build .#felis) against foot 1.28.0 (nix develop .#bench). I ran the harness vtebench suite (crossterm.py run --terminals foot vtebench,ROUNDS=3, leg order rotated) on kilimanjaro: i5-12400F,powersavegovernor, niri. The grid was 212x82 (DejaVu Sans Mono 10 pt) for every leg. Each value is the harness's per-leg mean in ms; lower is better.Before each round the harness was held until the 1-minute load was below 2.5 and no Forgejo Actions task was running. The load at each round's start was 0.68, 2.39 and 2.29. With nothing else running, a vtebench leg by itself lifts the 1-minute load to about 3.5; the peak inside any leg was 3.6-4.2.
Round orders: felis→foot, foot→felis, felis→foot.
scrolling.scrolling_top_small_regionoverlaps foot's range, by a single round (felis 125.2 against foot 127.1).scrolling_bottom_regionby 14 ms,scrolling_bottom_small_regionby 17.5 ms,scrolling_top_regionby 21 ms andscrolling_fullscreenby 4.8 ms.scrolling_fullscreentakes felis 12.7 ms in every round, against foot's 7.9 ms.The harness flagged one leg in each of rounds 2 and 3 as "unpinned (no window of this launch is mapped)": foot in round 2, felis in round 3. Both still ran at 212x82 cells and 1696 px wide, and their figures are in line with the pinned legs.
A second three-round run of the same build, started earlier the same afternoon, is discarded under the load rule. Rounds 2 and 3 started at load 3.8 and 6.3, and a CI cargo build lifted the load to 23 during foot's round-3 leg. Its medians had felis at or below foot on every scrolling row except
scrolling_fullscreen(13.4 against 8.0 ms). On the region rows the two runs differ by about as much as the felis-foot gap in the run above.Full cross-terminal run at
5f5e4957(nix build .#felis, which includes #447's scroll directives), on kilimanjaro: i5-12400F,powersavegovernor, niri, 106x35 cells, DejaVu Sans Mono 14 pt, the published page's workloads,ROUNDS=3with the leg order rotated. The CI runner was stopped for the whole run; the Windows CI guest stayed up. Every leg completed in every round. Each value is the harness's per-leg mean in ms; lower is better.Round orders: felis first in round 1, felis after foot in rounds 2 and 3.
scrolling_fullscreenis fixed at this grid: felis 6.2 ms against foot's 6.8 ms, ranges apart (at9d1d842fit was 6.1 against 7.0; the 212x82 run above had felis 12.7 against 7.9, not re-measured here).9d1d842f.The done condition (median at or below the Linux peers') is not met on this quiet run, so this stays open. The earlier finding still bounds what felis controls: the >100 ms level is the producer's PTY write path, and the reader already drains promptly. The remaining 25-33 ms gap to foot is not explained by that floor.
The republished page is in PR #456.
Profiling at origin/main
5f5e4957, headless sway at 106x35 and 14 pt, load 2-14 with the CI runner active. The load for every figure is in the scratch logs.The Linux vtebench scrolling rows are bound by where the scheduler places threads, not by felis's pipeline.
A bare PTY drain spans 77-260 ms. The drain is a forkpty that reads back to back with no parsing, and its time depends on where the writer, the reader and the unbound
kworkerrunningflush_to_ldiscland:The placement is chance. The worker stays on its previous CPU while that CPU is idle, and otherwise prefers a fully idle core.
felis is fastest when the placement is favorable. With the terminal pinned to CPUs 0-7 and vtebench to CPU 10, felis scored 82-103 ms, with the worker next to the writer 91-94% of the time. foot scored 93-206 ms in the same setup, with the worker next to the writer 51% of the time. Unpinned, felis scored 111-136 ms and foot 94-111 ms.
Some gap remains at equal placement. In two rounds where the placement was about the same, foot scored 104-107 ms and felis 120-128 ms. felis's reader makes about 1.2 M reads/s at about 10 B each, while foot's main thread makes about 0.4 M reads/s at about 38 B each.
Levers tried without a clear gain:
A reader that sleeps between reads still slows the producer.
Next steps:
perf c2c, orperf record -a -gon the writer and the flush worker in the three layouts). This needsperf_event_paranoid≤ 1.Parked for now. When this resumes, the next step is the kernel profile. Run
perf c2corperf record -a -gon the writer and the tty flush worker in the three layouts, to confirm the scheduling mechanism before changing felis or the benchmark method. This needsperf_event_paranoid≤ 1.