Skip to content

perf(renderer): stop the frame ticker while nothing changes - #1832

Open
viktordanov wants to merge 1 commit into
charmbracelet:mainfrom
viktordanov:park-idle-ticker
Open

viktordanov wants to merge 1 commit into
charmbracelet:mainfrom
viktordanov:park-idle-ticker

Conversation

@viktordanov

Copy link
Copy Markdown

What

startRenderer runs a time.Ticker at the program's frame rate for the program's whole life. Every tick flushes, even when nothing changed. cursedRenderer.flush returns early on an unchanged view, so nothing is repainted, but the process still wakes fps times 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:

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 stopRenderer doesn't wait for the old renderer goroutine, so its ticker.Stop() can land after the next run's ticker.Reset(), which leaves the program frozen after Exec. #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. WithFPS is 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:

Platform fps wakeups/s before after CPU ms/s before after
macOS (arm64), proc_pid_rusage idle + interrupt wakeups 30 94.8 0.2 1.98 0.009
60 186.3 0.3 2.99 0.010
Linux arm64 (Docker), voluntary context switches 30 122.1 0.7 6.98 0.020
60 244.3 0.8 10.43 0.023

The 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.

wakeups/s CPU ms/s
v2.0.9 162 3.49
this PR 3.7 0.05

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

case <-p.rendererWake:
	if !parked {
		continue
	}
	// Resume on the same frame boundaries as if the ticker had never stopped.
	parked, realign = false, true
	ticker.Reset(framerate - time.Since(last)%framerate)

case last = <-ticker.C:
	if realign {
		realign = false
		ticker.Reset(framerate)
	}
	_ = p.flush()
	if drew, _ := p.renderer.flush(false); drew {
		idle = 0
	} else if idle++; idle >= idleFrames { // fps/2: about half a second
		ticker.Stop()
		parked = true
	}
  1. renderer.flush returns (bool, error); the bool says whether there was anything to draw. cursedRenderer returns false on its existing viewEquals early return, and nilRenderer always returns false. The interface is unexported.
  2. After fps/2 consecutive ticks that drew nothing, the goroutine stops the ticker. It then waits on rendererDone or a new one-slot rendererWake channel.
  3. p.render and p.execute send to rendererWake without blocking. A wake while the ticker runs is ignored.
  4. On a wake, the ticker restarts on the frame boundaries it would have kept had it never stopped. Frames are drawn at the same moments as today, and the first frame after idle lands at the next boundary.
  5. Each startRenderer creates its own ticker and starts it running. A restarted renderer (after Exec, ReleaseTerminal/RestoreTerminal, or suspend) always draws its restore frame.
  6. A wake doesn't reset the idle count, so a message that leaves the view unchanged costs one tick, not another half second of ticking.

The non-test diff is +59/−17 across tea.go, renderer.go, nil_renderer.go and cursed_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. Each TestRendererWakes subtest follows the same steps:

  1. It waits until the renderer has stopped ticking.
  2. It takes the path.
  3. It asserts that the expected bytes reach the output.
  4. It waits for the renderer to park again and checks that a second change is drawn too.
Path Renderer entry Woken by TestRendererWakes/…
View content render p.render content
Cursor: show/move, shape, color render p.render cursor, cursor_shape, cursor_color
Window title render p.render window_title
Mouse mode render p.render mouse_cell_motion, mouse_all_motion
Focus reporting, bracketed paste render p.render report_focus, bracketed_paste
Keyboard enhancements render p.render keyboard_enhancements
Alt screen render p.render alt_screen
Foreground/background color render p.render foreground_color, background_color
Progress bar render p.render progress_bar
Mouse event → View.OnMouse → cmd onMouse the resulting message mouse_handler
Program.Println/Printf, Println/Printf cmds insertAbove p.render Program.Println, Program.Printf, Println, Printf
WindowSizeMsg resize p.render window_size
ClearScreen clearScreen p.render clear_screen
Raw, clipboard writes output buffer p.execute raw, set_clipboard, set_primary_clipboard
Queries: clipboard, colors, cursor position, version, termcap output buffer p.execute read_clipboard, read_primary_clipboard, background_color_query, foreground_color_query, cursor_color_query, cursor_position_query, terminal_version_query, capability_query
Output queued from outside the event loop output buffer p.execute execute
Mode 2026 report, then a frame setSyncdUpdates p.render synchronized_output
Mode 2027 report, then a frame setWidthMethod p.render unicode_core
ColorProfileMsg, then a frame setColorProfile p.render color_profile
Exec stopRenderer, start new run starts ticking exec
ReleaseTerminal + RestoreTerminal stopRenderer, start new run starts ticking release_and_restore
Suspend/resume the same release/restore path new run starts ticking Not separate: it needs a real SIGTSTP

Renderer 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), Quit as a cmd, Kill, Interrupt and context cancel each return from Run within 2 s with the expected error.
  • TestRendererParkedNoGoroutineLeak: restarts the renderer while parked, through Exec and through ReleaseTerminal/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, Raw and Exec. Each burst must end with its last frame drawn, and then the renderer must park.

Each of these mutations of tea.go makes at least one test fail:

Mutation Failing tests
No wake in render every TestRendererWakes subtest, TestRendererParkStress, and others
No wake in execute TestRendererWakes/execute
No realign TestRendererWakesOnFrameBoundary
Reset the idle count on wake TestRendererParksBetweenMessagesThatChangeNothing
Park after one empty frame TestRendererTicksAtFrameRateWhileActive
Never park the idle, shutdown, wake and stress tests

Comparison

These are the results of running this PR's test file against the other two branches. The only adaptation was the flush signature in the test's wrapping renderer.

#1776 #1796 this PR
Wake-path tests: every path in the table above pass pass, except release_and_restore and execute pass
First frame after idle immediate immediate next frame boundary, as today
Ticks while a view changes every 50 ms at 60 fps about 20/s (draws only) about 20/s 60/s, as today

In #1796, the ticker is stopped when the renderer starts and is armed only by a new view. A direct ReleaseTerminal/RestoreTerminal therefore doesn't redraw the restored frame and modes until the next message arrives. The Exec path 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

  • No public API change.
  • Frame timing is unchanged. The ticker runs as before while the view changes, and it resumes on the same frame boundaries after idle. Every existing test passes unchanged, including the golden tests in screen_test.go.
  • Shutdown and restart are unchanged. A parked goroutine still selects on rendererDone.

Testing

  • go test -race ./... (the coverage workflow's command) passes 10 of 10 runs on macOS, as main does.
  • The new tests pass 50 of 50 runs with go test -race -run TestRenderer -count 50, on macOS and on Linux.
  • golangci-lint run: no issues.
  • examples/ builds.
  • With the Taskfile's -count 4 -cpu 1,4, TestViewModel sometimes fails on this branch, as it does on main. That is TestViewModel is flaky under the Taskfile's test flags #1746: an intermediate tick can land before Quit. 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 that viewEquals ignores OnMouse. 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.

Measurement program
// Command idlebench measures the process wakeups and CPU time of an idle
// Bubble Tea program whose view never changes.
package main

import (
	"flag"
	"fmt"
	"io"
	"os"
	"syscall"
	"time"
	"unsafe"

	tea "charm.land/bubbletea/v2"
)

type model struct{}

func (model) Init() tea.Cmd                       { return nil }
func (model) Update(tea.Msg) (tea.Model, tea.Cmd) { return model{}, nil }
func (model) View() tea.View {
	v := tea.NewView("an idle program\nwhose view never changes\n")
	v.AltScreen = true
	return v
}

// wakeups (macOS): package idle + interrupt wakeups from proc_pid_rusage.
// On Linux, use syscall.Getrusage's Nvcsw instead.
func wakeups() uint64 {
	var ri struct {
		UUID                     [16]byte
		User, System, Idle, Intr uint64
		Rest                     [14]uint64
	}
	syscall.Syscall6(336, 9, uintptr(os.Getpid()), 2, 0, uintptr(unsafe.Pointer(&ri)), 0)
	return ri.Idle + ri.Intr
}

func cpu() time.Duration {
	var ru syscall.Rusage
	_ = syscall.Getrusage(syscall.RUSAGE_SELF, &ru)
	return time.Duration(ru.Utime.Nano() + ru.Stime.Nano())
}

func main() {
	fps := flag.Int("fps", 60, "frame rate")
	flag.Parse()
	p := tea.NewProgram(model{}, tea.WithInput(nil), tea.WithOutput(io.Discard),
		tea.WithFPS(*fps), tea.WithWindowSize(80, 24))
	go func() {
		time.Sleep(2 * time.Second)
		w0, c0 := wakeups(), cpu()
		time.Sleep(10 * time.Second)
		w1, c1 := wakeups(), cpu()
		fmt.Fprintf(os.Stderr, "fps=%d wakeups/s=%.1f cpu ms/s=%.3f\n",
			*fps, float64(w1-w0)/10, float64((c1-c0).Microseconds())/1000/10)
		p.Quit()
	}()
	_, _ = p.Run()
}

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.
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