Repository navigation
perf(renderer): stop the frame ticker while nothing changes - #1832
Open
viktordanov wants to merge 1 commit into
Open
viktordanov wants to merge 1 commit into
viktordanov wants to merge 1 commit into
Conversation
The renderer's ticker ran at the frame rate for the program's whole life, so an idle program woke 60 times a second by default even though nothing was drawn. The renderer goroutine now stops its ticker after about half a second of frames with nothing to draw, and p.render and p.execute wake it. On a wake the ticker resumes on the frame boundaries it would have kept, so frames are drawn exactly when they were before. Each run of the renderer gets its own ticker, so an exiting goroutine can't stop the next one's. renderer.flush now reports whether it drew anything.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
startRendererruns atime.Tickerat the program's frame rate for the program's whole life. Every tick flushes, even when nothing changed.cursedRenderer.flushreturns early on an unchanged view, so nothing is repainted, but the process still wakesfpstimes a second for as long as it runs.This PR stops the ticker after about half a second of frames with nothing to draw, and restarts it when there is something to draw. No public API changes.
Related work
Two open PRs address the same problem, and this one learned from both:
RestoreTerminal.Both PRs draw the first frame after idle immediately. That gives lower latency than today and fewer wakeups during sparse animation. This PR makes a different trade-off: it keeps today's frame timing exactly and covers every path that gives the renderer work with its own test. The test file from this PR runs unchanged against both branches; see "Comparison" below. Maintainers may prefer one design or the other. The tests should help whichever one lands, and I'm happy to move them onto either PR.
#1778 / #1781: today
stopRendererdoesn't wait for the old renderer goroutine, so itsticker.Stop()can land after the next run'sticker.Reset(), which leaves the program frozen afterExec. #1781 fixes this by waiting for the goroutine. Here, each run of the renderer creates its own ticker, so an exiting goroutine can only stop its own. That removes the race as a side effect, and the change is compatible with #1781.Why it matters
The change has no visible effect on a short-lived program. A TUI left open in a terminal wakes the CPU 60 times a second by default, which costs battery on a laptop and shows in macOS Activity Monitor's "Energy Impact" column.
WithFPSis the only lever today, and lowering it raises input latency.The measurements use a program whose view never changes (code at the end), over 10 s after 2 s to settle:
proc_pid_rusageidle + interrupt wakeupsThe same check in a real full-screen app: a coding-agent TUI at 30 fps, measured by its own perf harness over a 3 s idle window, median of 3 runs.
Its first-frame time, scrolling and turn measurements did not change beyond run-to-run noise, and its TUI test suite (347 tests,
-race) passes.How
renderer.flushreturns(bool, error); the bool says whether there was anything to draw.cursedRendererreturns false on its existingviewEqualsearly return, andnilRendereralways returns false. The interface is unexported.fps/2consecutive ticks that drew nothing, the goroutine stops the ticker. It then waits onrendererDoneor a new one-slotrendererWakechannel.p.renderandp.executesend torendererWakewithout blocking. A wake while the ticker runs is ignored.startRenderercreates its own ticker and starts it running. A restarted renderer (afterExec,ReleaseTerminal/RestoreTerminal, or suspend) always draws its restore frame.The non-test diff is +59/−17 across
tea.go,renderer.go,nil_renderer.goandcursed_renderer.go.Every path that gives the renderer work, and its test
Every message the event loop handles ends with
p.render(model). That covers every renderer method the event loop calls, because each call is followed by a render and so by a wake. EachTestRendererWakessubtest follows the same steps:TestRendererWakes/…renderp.rendercontentrenderp.rendercursor,cursor_shape,cursor_colorrenderp.renderwindow_titlerenderp.rendermouse_cell_motion,mouse_all_motionrenderp.renderreport_focus,bracketed_pasterenderp.renderkeyboard_enhancementsrenderp.renderalt_screenrenderp.renderforeground_color,background_colorrenderp.renderprogress_barView.OnMouse→ cmdonMousemouse_handlerProgram.Println/Printf,Println/PrintfcmdsinsertAbovep.renderProgram.Println,Program.Printf,Println,PrintfWindowSizeMsgresizep.renderwindow_sizeClearScreenclearScreenp.renderclear_screenRaw, clipboard writesp.executeraw,set_clipboard,set_primary_clipboardp.executeread_clipboard,read_primary_clipboard,background_color_query,foreground_color_query,cursor_color_query,cursor_position_query,terminal_version_query,capability_queryp.executeexecutesetSyncdUpdatesp.rendersynchronized_outputsetWidthMethodp.renderunicode_coreColorProfileMsg, then a framesetColorProfilep.rendercolor_profileExecstopRenderer,startexecReleaseTerminal+RestoreTerminalstopRenderer,startrelease_and_restoreRenderer behaviour tests:
TestRendererStopsTickingWhenIdle: after parking, zero ticks in a second.TestRendererParksBetweenMessagesThatChangeNothing: 10 messages that change nothing, 200 ms apart, cost at most 30 ticks. Without parking that window is 120 ticks.TestRendererTicksAtFrameRateWhileActive: a view changing every 50 ms keeps the ticker at the frame rate.TestRendererWakesOnFrameBoundary: at 10 fps, a change made half way between two frame boundaries is drawn on the old ticker's next boundary (within 25 ms of it), and within one interval of the change.TestRendererShutdownWhileParked:Quit(its final frame is drawn),Quitas a cmd,Kill,Interruptand context cancel each return fromRunwithin 2 s with the expected error.TestRendererParkedNoGoroutineLeak: restarts the renderer while parked, throughExecand throughReleaseTerminal/RestoreTerminal, then quits or kills. Exactly one renderer goroutine runs during the program and none after. This test is not parallel, because it counts goroutines in the process.TestRendererParkStress: three bursts of 300 ms at 120 fps, from 8 goroutines sending views,Println, resizes,RawandExec. Each burst must end with its last frame drawn, and then the renderer must park.Each of these mutations of
tea.gomakes at least one test fail:renderTestRendererWakessubtest,TestRendererParkStress, and othersexecuteTestRendererWakes/executeTestRendererWakesOnFrameBoundaryTestRendererParksBetweenMessagesThatChangeNothingTestRendererTicksAtFrameRateWhileActiveComparison
These are the results of running this PR's test file against the other two branches. The only adaptation was the
flushsignature in the test's wrapping renderer.release_and_restoreandexecuteIn #1796, the ticker is stopped when the renderer starts and is armed only by a new view. A direct
ReleaseTerminal/RestoreTerminaltherefore doesn't redraw the restored frame and modes until the next message arrives. TheExecpath does redraw, because the event loop renders after it.The two timing rows are deliberate design differences, not defects. With a 10 Hz animation at 60 fps, the process wakes about 47 times a second with #1776, 74 with #1796, and about 200 with this PR (the same as
main). If maintainers prefer drawing immediately and the lower animation cost, #1776's approach gets that. This PR's tests then apply to it after dropping the two timing tests.Compatibility
screen_test.go.rendererDone.Testing
go test -race ./...(the coverage workflow's command) passes 10 of 10 runs on macOS, asmaindoes.go test -race -run TestRenderer -count 50, on macOS and on Linux.golangci-lint run: no issues.examples/builds.-count 4 -cpu 1,4,TestViewModelsometimes fails on this branch, as it does onmain. That is TestViewModel is flaky under the Taskfile's test flags #1746: an intermediate tick can land beforeQuit. Its failure rate depends on load, and the new tests add load. test: stabilize view model golden output #1752 stabilizes it.While writing
mouse_handler, I noticed thatviewEqualsignoresOnMouse. A view that changes only its handler is therefore never stored, and the new handler is never called. That is unrelated to this PR, and #1815 fixes it. The test changes the content along with the handler.CONTRIBUTING.md.Measurement program