Skip to content

native: terminal output rides the busy ring's frame; the ring at 24fps - #1011

Merged
omartelo merged 1 commit into
mainfrom
perf/native-output-rides-loop
Nov 1, 2026
Merged

omartelo merged 1 commit into
mainfrom
perf/native-output-rides-loop

Conversation

@omartelo

@omartelo omartelo commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator

Why

Measured on a real window (1704x1386, one busy session with its agent streaming): 41 frames a second, 13.6% of the GPU. The busy ring asks for 30 frames a second, and every chunk of the session's output asks for a frame of its own; the two never line up, so they add. Each frame costs the same fixed GPU time whatever it shows, so on this stack the window's cost is simply frames × that.

Same rig, same 12fps TUI spinner, same GPU: Ghostty 0.8–1.4%, Kitty 2.6–7.4%, lich with the session idle (only the TUI asking) 4–12% at 12fps, lich with the session busy 7–11% at 36fps.

What

  • Theme.Loop records the frame it asked for in a ui.LoopBeat the window shares with its terminals. Output that lands while a looping frame is due waits for it instead of asking for its own: a busy ring and a streaming agent cost one frame rate. Output within 200ms of a write to the PTY is a keystroke's echo and draws at once, as before. With nothing looping, output draws at once, as before.
  • ui.LoopFrame goes from 33ms to 42ms: 24fps, film's rate. On a 22px ring the arc's tip moves 2.6px a frame instead of 2.1, and reads the same.

Together, a busy session with a streaming agent draws 24 frames a second instead of 41.

Test plan

  • TestLoopingAnimationsRedrawAt24fps, TestLoopRecordsTheFrameItAskedFor, TestStillThemeHoldsTheFrame
  • TestOutputRidesTheLoopingFrame: output asks for its own frame with nothing looping, rides a frame that is due, asks again once it is past
  • TestAKeystrokesEchoNeverWaits
  • native/: gofmt, go vet ./..., go test ./...

A busy ring asked for 30 frames a second and every chunk of the session's
output asked for one of its own, so a streaming agent under a spinning ring
drew 41 frames a second in a real window, each costing the same fixed GPU
time whatever it showed. Theme.Loop now records the frame it asked for in
a LoopBeat the window shares with its terminals, and output that lands
while one is due waits for it; output within 200ms of a write to the PTY,
a keystroke's echo, still draws at once. The ring runs at 24fps, film's
rate, which on a 22px ring reads the same as 30.
@omartelo
omartelo merged commit f569ea8 into main Nov 1, 2026
7 checks passed
@omartelo
omartelo deleted the perf/native-output-rides-loop branch November 1, 2026 21:53
omartelo added a commit that referenced this pull request Nov 1, 2026
Measured after #1011, a busy session with its agent streaming still drew
37 frames a second, not 24: the terminal's answers to the program's
queries (cursor position, device attributes, which Claude Code asks for as
it draws) went through send and counted as someone typing, so the output
that followed always drew at once instead of riding the ring's frame.
Replies now go through their own write and leave the echo window closed.
omartelo added a commit that referenced this pull request Nov 2, 2026
… beat, a cursor row left alone (#1013)

* native: fewer idle frames: a slow ring without focus, a rarer service beat, a cursor row left alone

Every frame costs the window the same whatever it shows, about 0.3% of a
laptop GPU, so what is left after #1011 is the frames nobody asked for:

- Without focus the ring stopped dead; now it turns at 8fps, so a session
  is still seen working from another monitor, at a fifth of the frames.
- A running service's dot beat every 2.4s, a burst of frames each time:
  8 frames a second all day for a 14dp dot. It beats every 6s.
- The terminal recomposed and re-uploaded the cursor's row on every frame
  while the cursor stayed put. The row is recomposed when the cursor moves
  or the row's own text changes.

* native: the surplus-frame test judges its own log line

TestBrowserFrameSurplusDecodesOnlyTheNewest captured the process log and
wanted it empty, but a layout save of an earlier test, failing after that
test's backend closed, logs there too: on the macOS runner it did. The
test now looks for the decode's own line, logged under the event's name.
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