cocoa: convert only the damaged rows, so reporting damage does something - #117
Merged
Merged
Conversation
Incremental present was wired end to end and saved nothing. Measured on a
window presenting at 60 Hz, same binary, one bool changed: 26.2% of a core
with whole-surface present, 26.8% with damage reporting. Slightly worse, not
better.
Sampling the process said why. -drawRect: wrapped the WHOLE framebuffer in a
fresh NSBitmapImageRep and drew it once, relying on AppKit's clip to limit
what reached the screen — which it does. What it does not limit is the
conversion:
-[NSView displayIfNeeded]
-> drawRect:
-> -[NSImageRep drawInRect:fromRect:...]
-> -[NSBitmapImageRep CGImage]
A rep has no backing CGImage until it is drawn, so every present converted
every row whatever the invalid region said. The clip narrowed the blit and
left the conversion where it was.
A run of rows is contiguous in the framebuffer, so it can be wrapped as a
bitmap of its own. presentRects now hands its rectangles to -drawRect:
through the window, DrawBands reduces them to merged row runs, and each run
is drawn as its own rep into its own destination rect, so the conversion is
proportional to what changed.
WHAT IT IS WORTH, measured rather than implied: a window repainting a small
region every frame at 60 Hz costs 22.2% of a core before and 19.1% after,
two runs each, consistent in direction. That is ~14% off the frame cost —
real and reproducible, and well short of what the profile suggested. The
conversion was about a seventh of the cost, not the bulk of it; the rest
sits below this layer and is not addressed here.
An AppKit-initiated draw — an expose, a resize, the first display — finds no
pending rectangles (they are consumed by the draw that reads them, and
presentFull clears them) and falls back to one band covering the buffer,
which is exactly the draw this replaces.
The row arithmetic lives in mapping.go, the OS-independent half the Linux
lane measures, and is covered by construction: bands merge when they touch
as well as when they overlap, clamp to the buffer, drop what falls outside,
and BandDest places each band where the whole-buffer draw would have put
those same rows — with adjacent bands meeting exactly, since a gap between
two of them is a stale line across the window.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 11, 2026
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 defect
Incremental present is wired end to end on macOS —
Surface.Damage→RenderDamaged→presentRects→setNeedsDisplayRect— and it savednothing. Measured on a window presenting at 60 Hz, same binary, one bool
changed:
Sampling the process said why.
-drawRect:wrapped the whole framebufferin a fresh
NSBitmapImageRepand drew it once, relying on AppKit's clip tolimit what reached the screen — which it does. What it does not limit is the
conversion:
A rep has no backing CGImage until it is drawn, so every present converted
every row whatever the invalid region said.
The change
A run of rows is contiguous in the framebuffer, so it can be wrapped as a
bitmap of its own.
presentRectshands its rectangles to-drawRect:throughthe window,
DrawBandsreduces them to merged row runs, and each run is drawnas its own rep into its own destination rect. The conversion is then
proportional to what changed.
An AppKit-initiated draw — an expose, a resize, the first display — finds no
pending rectangles and falls back to one band covering the buffer, which is
exactly the draw this replaces.
What it is worth
Measured rather than implied: a window repainting a small region every frame
at 60 Hz costs 22.2% of a core before and 19.1% after, two runs each,
consistent in direction. That is ~14% off the frame cost — real and
reproducible, and well short of what the profile suggested. The conversion was
about a seventh of the cost, not the bulk of it; the rest sits below this layer
and is not addressed here.
Tests
The row arithmetic is in
mapping.go, the OS-independent half the Linuxcoverage lane measures, and is covered by construction:
seam twice costs more than the row it saves;
a row left out is a row the screen keeps stale;
BandDestplaces a band where the whole-buffer draw would have put thosesame rows, and adjacent bands meet exactly, since a gap between two of
them is a stale line across the window.
internal/cocoamapping.gostays at 100% statement coverage. The livedarwin repaint proof passes.
🤖 Generated with Claude Code