perf(renderer): read the viewport once per frame - #191
Open
Gaurav-Gosain wants to merge 1 commit into
Open
Conversation
CanvasRenderer.render() paints a frame one row at a time, and each row calls
buffer.getLine(). getLine() is a compatibility shim: it calls getViewport(),
which reads every cell in the grid, and then returns one row of it. A frame
that paints R rows therefore reads the whole grid R times. Every read after
the first is discarded.
Read the viewport once at the top of render() and slice each row out of it.
The memo is a local variable inside render(), so it cannot outlive the call.
There is no cache to invalidate. Callers outside render(), including
SelectionManager, keep taking the original getLine() path. Scrolled rows keep
the old path, because they come from the scrollback provider.
getViewport() is added to IRenderable as an optional method, so a buffer that
does not implement it behaves as before.
Measured on a 114x42 grid in Chromium on an NVIDIA RTX 3070, using
demo/viewport-repro.html which is included here:
grid reads per frame 22.0 -> 1.0
render() mean 66.48ms -> 7.41ms
render() p95 131.98ms -> 7.63ms
frames in 4 seconds 58 -> 240
The renderer issues more 2D operations per second after the change, not
fewer, because it is no longer starved. Drawing was not the bottleneck.
Gaurav-Gosain
force-pushed
the
perf/viewport-once-per-frame
branch
from
September 1, 2026 05:00
3498ae0 to
55bd70e
Compare
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.
The mechanism
CanvasRenderer.render()paints a frame one row at a time:getLine()is a compatibility shim. It builds the whole viewport and returnsone row of it:
So a frame that paints R rows reads the whole grid R times. The read it repeats
is the expensive one:
getViewport()fills the buffer throughghostty_render_state_get_viewportand then parsescols * rowscells into thepool. Every one of those reads but the first is discarded.
The row-level caching that exists does not cover this.
isRowDirtyand, inforks that have it,
isRowWrapped, are cached per row. The viewport cell poolis the one read that is not.
The tell that this is a read problem and not a drawing problem: after the
change the renderer issues more 2D operations per second, because it is no
longer starved, and it is still several times faster. Drawing was never the
bottleneck.
The change
Read the viewport once at the top of
render()and slice each row out of it.The memo is a local variable inside
render(). It cannot outlive the call, sothere is no cache to invalidate and no staleness question:
render()runs tocompletion before any write can land, and every caller outside
render(),SelectionManagerincluded, keeps taking the originalgetLine()path.Scrolled frames keep the old path, because those rows come from the scrollback
provider rather than the viewport.
getViewport()is added toIRenderableas an optional method, so anybuffer that does not implement it behaves exactly as before.
The patch keeps the per-cell copy that
getLine()makes, so callers stillnever hold a reference into the reused pool. That leaves a second, larger win
on the table: inside one frame the pool is stable, so the copy is not needed
there. I left it out to keep this diff about one variable.
Reproduction
demo/viewport-repro.htmlis in the patch. Runbun run devand open/demo/viewport-repro.html. It writes a full screen of changing text at 60 Hz,counts
getViewport()calls per frame and timesrender(), with and withoutthe change, in one page.
114x42 grid, Chromium 4 s per arm, NVIDIA RTX 3070:
getLine()per rowA second measurement, on a real page
tuiffects (https://github.com/Gaurav-Gosain/tuiffects) is a terminal animation
catalogue compiled to wasm and drawn through ghostty-web. It repaints most of a
114x42 grid every frame, which is the load this path is worst at.
Nine paired rounds per effect, interleaved so machine drift cancels, 2.5 s per
round, load gated below 1-minute loadavg 4, all 72 rounds accepted, same
RTX 3070:
Whole browser process tree CPU, seconds per wall second, matrix: 0.76 to 0.42.
Full-grid reads per frame, matrix: 5.3 to 0.9.
Notes
renderLine()are thesame objects with the same contents.
getLine()against a fresh read at a moment between frames, and it doescatch a memo that outlives
render(): such a memo fails 40 of 40 trials.The change in this patch passes 40 of 40, as does stock.
GhosttyTerminalinvalidated by writes and resizes, which would also skip the read on idle
frames. That needs every mutation path enumerated to stay correct. This patch
does not, which is why it is shaped this way.