Skip to content

perf(icons): chrome glyphs come from one shared bitmap per glyph, size and scale (REOPEN-F7 part A2) - #32481

Merged
lawrencecchen merged 8 commits into
feat-cmux-nextfrom
fix/reopen-f7-parta2-icon-cache
Nov 2, 2026
Merged

lawrencecchen merged 8 commits into
feat-cmux-nextfrom
fix/reopen-f7-parta2-icon-cache

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

REOPEN-F7 part A2. After a reopen, AppKit's first display of the restored window spends 37-43 ms on the main thread before the first frame. Whole stall stacks (10 warm realistic reopens, fleet job cea96828b32f4e6d7f183e8d on cmux18) show chrome glyphs rasterized one view at a time there: -[_NSSimpleImageView updateLayer] -> NSImage CGImageForProposedRect -> NSCGImageSnapshotRep / NSImageSymbolRepProvider -> CGBitmapAllocateData.

Cause: NSImage.icon(_:size:style:) / icon(symbol:size:style:) (about 105 callers) returned a new drawing-handler NSImage per call, and AppKit keeps one raster per image instance. N views showing the same glyph rasterized it N times.

Chrome's model is one shared image cache. IconRasters (CmuxNextIcons) draws each glyph once per key (icon name, style, SF Symbol, pack family overlay, side, scale 1x/2x), from any thread, in black on clear (template). NSImage.icon returns a new template NSImage per call whose representations wrap the shared bitmaps, so a caller that sets its own image's size or description changes no other. The drawing code is the same as before (pack layers y-down, or the SF Symbol fitted and centered).

  • Bounded (lead, STATUS 7066): at most 512 bitmaps and 8 MB, least recently used dropped first; every bitmap dropped on a memory pressure warning (its own kernel dispatch source on a utility queue). One unfair lock held only to look up or store; drawing runs outside it; no main-thread hop.
  • Three fallback glyphs were drawn larger than they were asked for; a bitmap would be stretched up, so each now asks for its drawn size: the media row's artwork (40 pt), the import row's browser icon (30 pt), an extension button with no icon (the button's side).
  • Architecture: IconRasters.shared is fileprivate (only NSImage.icon reaches it) and lands under a reviewed, dated exception, plans/cmux-next/arch-exceptions/icon-rasters.json (stateful_singletons +1, expiry 2026-11-16). Follow-up Icons: move NSImage.icon callers to an injected icon provider and remove the IconRasters singleton exception #32444 moves callers to an injected icon provider and removes it.

Not in this PR: TitlebarBandButton.renderSymbol and SidebarToggleIcon make their own symbol images per instance (not through NSImage.icon).

Testing

Regression tests (committed first):

  • IconRasterSharingTests: 8 identical glyphs (a pack icon at 1x and 2x, an SF Symbol) give one bitmap per key, and the images' own representations are that bitmap; another size is another key; each caller owns its image (size, description).
  • IconRastersBoundTests: the count limit drops the least recently used; the byte limit holds; a drop keeps nothing.

Fleet runs (package-test-lane.sh suite, through proof-step.py):

  • Red, 37d9817 (the test on feat 82eddd7): job 3c8c89abf8e3279852e971c9: IconRasterSharingTests FAILS: 8 bitmaps for 8 identical glyphs, at 1x, at 2x and for the SF Symbol.
  • Green, head afa4fd3: job 94e766941b50e2cc8a6e53ba: IconRasterSharingTests, IconRastersBoundTests, BrowserChromeIconsTests, BrowserToolbarExtensionsButtonTests, SidebarItemGlyphStaysTests all passed.
  • Compile checks passed at the head (job 6053116c) and at the red ref.

Review: an independent review subagent gave SHIP at dd6e954 (3 P2 fixed since) and SHIP at 0a428af; the exception file carries that receipt.

Launch A/B (A1 + A2 together, lead order): AB_LINE

Observed / Not run

  • Observed: the stall stacks; the red and green steps.
  • Not run: a cold first-launch measurement (the first miss of each key draws both scales; review P3).

Changelog

none

Proof

No capture: the glyphs draw the same pixels; the tests pin the sharing.


Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Chrome glyphs now come from one shared bitmap per glyph, size, and scale, so N views showing the same icon rasterize it once instead of N times. That removes the 37–43 ms AppKit stall in the first display pass after reopening a window.

  • NSImage.icon still returns a new template NSImage per call; the image's representations wrap the shared bitmaps, so callers setting size or description change no other image.
  • The cache holds at most 512 bitmaps and 8 MB, drops the least recently used first, and clears on memory pressure.
  • Three fallback glyphs (media row artwork, import row browser icon, extension button without an icon) now ask for their drawn size so a bitmap is not stretched up.
  • Tests: IconRasterSharingTests pins the sharing; IconRastersBoundTests pins the limits and drops.

Written for commit 57569f6. Summary will update on new commits.

View guided diff Turn on auto-fix

…-F7 part A2, red)

Whole stall stacks after a reopen (feat 52a6fbb, 10 warm realistic runs, cmux18 job
cea96828b32f4e6d7f183e8d): AppKit's first display pass of the restored window (37-43
ms) rasterized chrome glyphs one view at a time (-[_NSSimpleImageView updateLayer] ->
NSImage CGImageForProposedRect -> NSCGImageSnapshotRep / NSImageSymbolRepProvider ->
CGBitmapAllocateData). NSImage.icon returns a new drawing-handler image per call, and
AppKit keeps one raster per image instance, so N views with the same glyph rasterize
it N times.

IconRasterSharingTests: 8 identical glyphs (a pack icon at 1x and 2x, an SF Symbol)
give one bitmap per key; another size is another key; every caller still owns its
image (size, description). Fails here: each image rasterizes its own bitmap.
…ide and scale (REOPEN-F7 part A2)

NSImage.icon returned a new drawing-handler NSImage per call. AppKit keeps one raster
per image instance, so N views showing the same glyph rasterized it N times on the
main thread; after a reopen that work was part of the first window's 37-43 ms display
pass (whole stall stacks, cmux18 job cea96828: NSCGImageSnapshotRep,
NSImageSymbolRepProvider, CGBitmapAllocateData).

IconRasters (CmuxNextIcons) draws each glyph (name, style or SF Symbol) once per side
and scale (1x, 2x), from any thread, in black on clear, and keeps the bitmap.
NSImage.icon returns a new template NSImage per call whose representations wrap the
shared bitmaps, so a caller that sets its own image's size or description changes no
other. The drawing code is the same as before (pack layers y-down, or the SF Symbol
fitted and centered), so an icon at its own size draws the same pixels.

Red: IconRasterSharingTests fails on the parent. Green: the same suite here.
…ory pressure (REOPEN-F7 part A2)

Lead (STATUS 7066): the process-wide raster cache keeps at most 512 bitmaps and 8 MB,
the least recently used dropped first, and drops every bitmap on a memory pressure
warning (its own dispatch source on a utility queue). One unfair lock, held only to
look up or store; drawing runs outside it; no main-thread hop.

IconRastersBoundTests: the count limit drops the least recently used, the byte limit
holds, a drop keeps nothing.
…art A2)

Review (subagent) SHIP at dd6e954 with:
- P2: three fallback glyphs were asked for smaller than the view draws them, now a
  bitmap stretched up: the media row's artwork (20 -> 40 pt), the import row's
  browser icon (16 -> 30 pt), an extension button with no icon (toolbar size -> the
  button's side). Each now asks for the drawn size.
- The cache is fileprivate; NSImage.rasterIcon is its only door.
- The pack's family overlay is part of the key, so a glyph drawn before
  IconPack.useOverlay is not reused after it.
- The test also checks that the images' own representations are the shared bitmap.
- ChiefPetRail's comment named a drawing handler the source no longer has.
…teful_singletons +1, expiry 2026-11-16)

Lead decision (STATUS 7066 answer): one reviewed, dated exception for the process-wide
raster cache. Follow-up issue #32444 moves the callers to an injected icon provider and
removes it.
…n allSatisfy did not compile there)

Fleet compile 88f96c33 at 5b9405a: IconRasterSharingTests.swift:37 'call can throw, but it
is not marked with try' inside the #expect macro expansion.
@lawrencecchen lawrencecchen added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

Review afa4fd3: HQ feed card · gallery before/after runs · checks

App of this head (app.zip, kept 1 day)

gh workflow run reload-build.yml -R manaflow-ai/cmux --ref feat-cmux-next -f tag=pr32481 -f ref=fix/reopen-f7-parta2-icon-cache -f platform=macos -f backend_mode=local

Default changes

This pull request changes 2 defaults at 57569f6c32c6 (what everyone gets without configuring anything):

  • tunable-source.CmuxNextIcons/IconPack.swift:overlay: (none) -> "38d359241c6b7d9342e05974c4e08c29d90b126c3643beef7bc18dbcb20a0a99"
  • tunable-source.CmuxNextIcons/NSImage+Icon.swift:icon##: "859d11f553fd4b5f61fadc787555bac22746512fdff42bcc59d5fa1e3c1ca03a" -> "eaafc02c3a02dddf31a7c442986a30577ab00dd1db28436556c6ae6e900a37ce"

Signed off on this commit by review (next-review patch-id; carried).

Merge receipt: merged as c373b5053a60 with these defaults changed, signed off by review (next-review patch-id; carried).

Regenerate the snapshot with python3 scripts/ci/defaults_guard.py snapshot.

Shipped in:

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (subagent, correctness first): SHIP at afa4fd3

I read the whole change (82eddd7..afa4fd3) and did not build it locally. The fleet compile (6053116c), the green test run (94e76694) and the red run on the base (3c8c89ab: 8 bitmaps for 8 glyphs) are the run evidence. Each pack drawing and each SF Symbol draws once for each side and scale (1x, 2x). The output matches the old drawing-handler path in pixels: y-down pack layers, a fitted and centered symbol, black ink, and premultiplied sRGB, so template tint is unchanged. The key is complete: name, style, symbol, overlay, side and scale. The cache is thread-safe: lookup and store are under the lock, and drawing is outside it. The cache is bounded with LRU at 512 bitmaps / 8 MB. The eviction guard keeps the new entry and stops the loop. A memory warning empties the cache. The 3 fallback views that drew a glyph larger than its size now ask for the drawn size, so no glyph is stretched up from a bitmap. Since 0a428af there are 2 commits: one only moves a test key path out of #expect, and one adds the arch exception (stateful_singletons +1, owner, expiry 2026-11-16, follow-up #32444). Its receipt has the correct form for arch-ratchet.py. Small notes that do not block: a second kernel memory-pressure source (named in the exception), and the reopen before/after time still to be recorded from the fleet.

@cmux-bot
cmux-bot Bot temporarily deployed to ci-routing-agent November 2, 2026 17:51 Inactive
@cmux-bot
cmux-bot Bot temporarily deployed to ci-routing-agent November 2, 2026 17:51 Inactive
@teamleaderleo

Copy link
Copy Markdown
Collaborator

Review (next-review patch-id; carried): SHIP at dc820c3

The independent SHIP at afa4fd3 carries to this head: the PR's own change (git diff $(git merge-base feat-cmux-next HEAD) HEAD) has the same git patch-id --verbatim (38212cf9506403b4) at both heads, and no merge between them was resolved by hand. Nothing new to review. A newer BLOCK, or any change to the PR's own diff, ends the carry.

@cmux-bot
cmux-bot Bot temporarily deployed to ci-routing-agent November 2, 2026 19:57 Inactive
@cmux-bot
cmux-bot Bot temporarily deployed to ci-routing-agent November 2, 2026 19:57 Inactive
@teamleaderleo

Copy link
Copy Markdown
Collaborator

Review (next-review patch-id; carried): SHIP at 57569f6

The independent SHIP at afa4fd3 carries to this head: the PR's own change (git diff $(git merge-base feat-cmux-next HEAD) HEAD) has the same git patch-id --verbatim (38212cf9506403b4) at both heads, and no merge between them was resolved by hand. Nothing new to review. A newer BLOCK, or any change to the PR's own diff, ends the carry.

@lawrencecchen lawrencecchen added the ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds label Nov 2, 2026
@lawrencecchen
lawrencecchen merged commit c373b50 into feat-cmux-next Nov 2, 2026
200 of 207 checks passed
@lawrencecchen
lawrencecchen deleted the fix/reopen-f7-parta2-icon-cache branch November 2, 2026 21:26
lawrencecchen added a commit that referenced this pull request Nov 3, 2026
… shows (#33112)

* test(sidebar): a hidden icon button makes no image until it shows (REOPEN-F7, red)

A reopened window's sidebar makes 5 icon buttons per workspace row,
hidden until hover, and each made its icon image at init: about 4 ms of
the profiled window build before the first frame (profile 8,
SidebarIconButton.renderIcon). These tests fail at the base: a hidden
button, or one in a hidden row, already has its image.

* perf(sidebar): an icon button makes its image when it first shows, not at init (REOPEN-F7)

Each workspace row makes 5 icon buttons (close, audio, pin, tree fold,
more) that stay hidden until the pointer is on the row; each made its
template image (an NSImage with a bitmap per scale) at init. Profile 8
(cmux19, job 72f71795): SidebarIconButton.renderIcon about 4 ms and the
buttons' own layout 2 ms of the window build before the first frame.

renderIcon now returns while the button is hidden or in a hidden row.
It runs when the button shows (viewDidUnhide, also sent for an
ancestor), joins a window, lays out, or is asked its intrinsic size. An
icon, label or symbol change on a hidden button draws when it shows.
The look does not change: a shown button has its image before it draws.
The shared glyph bitmaps (#32481) stay as they are.

Sidebar-Look: no visual change

* test(sidebar): the lazy-image test reads the icon, not AppKit's 1 x 1 placeholder

The green step 6bc3ff4c failed: a hidden button's image read as a
1 x 1 NSImage, the same object in every test. AppKit answers a button
that has no image of its own with a shared placeholder, so image == nil
never held. The test now counts an image as the icon only when it is
at least 2 points wide.

Sidebar-Look: no visual change

* fix(sidebar): a button asked its size makes its icon, read from its own state (green step 1844f51d)

aShownButtonHasItsImageInAWindowAndWithoutOne failed at d567840: a
button outside a window, asked its intrinsic size, kept AppKit's 1 x 1
placeholder image, because the check was image == nil and a button
with no image of its own answers that placeholder. A stack or a
constraint reading that size would size the button 1 x 1. The check is
now renderedSize == 0 (no icon made yet).

Sidebar-Look: no visual change

* test(sidebar): a new workspace row's hover controls start hidden, with no image (review P3, red)

Review of #33112 at bc142a2 (P3): the row's Unpin button was not
hidden at init, so it made its image when the row joined the window,
before the row's first layout hid it. Fails at bc142a2 on the pin.

Sidebar-Look: no visual change

* perf(sidebar): a workspace row's Unpin button starts hidden (review P3)

It shows only on hover of an expanded pinned row (layoutPin through
setHoverShown, which unhides a hidden view the same way), and the row's
first layout hid it anyway. Hidden at init, it makes no image before
the first frame, as the close, audio and tree fold buttons.

Sidebar-Look: no visual change

---------

Co-authored-by: cmux-bot[bot] <348531757+cmux-bot[bot]@users.noreply.github.com>
lawrencecchen added a commit that referenced this pull request Nov 3, 2026
… first shows (stacked on #33112) (#33113)

* test(sidebar): a hidden icon button makes no image until it shows (REOPEN-F7, red)

A reopened window's sidebar makes 5 icon buttons per workspace row,
hidden until hover, and each made its icon image at init: about 4 ms of
the profiled window build before the first frame (profile 8,
SidebarIconButton.renderIcon). These tests fail at the base: a hidden
button, or one in a hidden row, already has its image.

* perf(sidebar): an icon button makes its image when it first shows, not at init (REOPEN-F7)

Each workspace row makes 5 icon buttons (close, audio, pin, tree fold,
more) that stay hidden until the pointer is on the row; each made its
template image (an NSImage with a bitmap per scale) at init. Profile 8
(cmux19, job 72f71795): SidebarIconButton.renderIcon about 4 ms and the
buttons' own layout 2 ms of the window build before the first frame.

renderIcon now returns while the button is hidden or in a hidden row.
It runs when the button shows (viewDidUnhide, also sent for an
ancestor), joins a window, lays out, or is asked its intrinsic size. An
icon, label or symbol change on a hidden button draws when it shows.
The look does not change: a shown button has its image before it draws.
The shared glyph bitmaps (#32481) stay as they are.

Sidebar-Look: no visual change

* test(sidebar): a hidden icon button registers no tooltip until it shows (REOPEN-F7, red)

Each workspace row's hover-only buttons registered their themed tooltip
(a tracker in the tooltip table, a tracking area) at init, before the
first window frame: about 1 ms profiled (profile 8). Fails at the base:
a hidden button already has its tracker.

* perf(sidebar): an icon button registers its tooltip when it first shows (REOPEN-F7)

Each workspace row's hover-only icon buttons put a tracker in the
tooltip table and a tracking area on themselves at init, before the
first window frame (TooltipTracker.setWhole, about 1 ms profiled in
profile 8), though a hidden button cannot be hovered.

applyToolTip registers the label as the themed tooltip once the button
shows (unhide, also of an ancestor, or joining a window). Until then it
sets only the VoiceOver help, so a hidden button still has its help.
A tooltip already registered follows a label change at once.

Sidebar-Look: no visual change

* test(sidebar): the lazy-image test reads the icon, not AppKit's 1 x 1 placeholder

The green step 6bc3ff4c failed: a hidden button's image read as a
1 x 1 NSImage, the same object in every test. AppKit answers a button
that has no image of its own with a shared placeholder, so image == nil
never held. The test now counts an image as the icon only when it is
at least 2 points wide.

Sidebar-Look: no visual change

* fix(sidebar): a button asked its size makes its icon, read from its own state (green step 1844f51d)

aShownButtonHasItsImageInAWindowAndWithoutOne failed at d567840: a
button outside a window, asked its intrinsic size, kept AppKit's 1 x 1
placeholder image, because the check was image == nil and a button
with no image of its own answers that placeholder. A stack or a
constraint reading that size would size the button 1 x 1. The check is
now renderedSize == 0 (no icon made yet).

Sidebar-Look: no visual change

* test(sidebar): a shortcut set before a button shows is kept; the tooltip reads back with no window (review P1, red)

Review of #33113 at e2c9ba9: showPriority sets the Priority bell's
themedToolTipShortcut on a sidebar that may have no window yet. With
the deferred registration, TooltipTracker.setWhole(nil, shortcut:)
returned early and the shortcut was lost (SidebarIconMeaningTests.
thePriorityBellNamesItsDotAndKeepsTheShortcutApart fails too). These
tests fail at e2c9ba9. The earlier tests now check the tooltip
tracking area, not the tracker table: the deferred part is the hover
tracking, not the text.

Sidebar-Look: no visual change

* fix(sidebar): keep a hidden button's tooltip text and shortcut; defer only its tracking area (review P1)

Review of #33113 at e2c9ba9 (P1): showPriority sets the Priority
bell's themedToolTipShortcut on a sidebar that may have no window yet.
The deferred registration made TooltipTracker.setWhole(nil, shortcut:)
return early, so the bell lost its shortcut, and themedToolTip read nil
before the button showed (SidebarIconMeaningTests.thePriorityBell...).

Now the tracker always records the text and the shortcut. Only the
hover tracking area waits: NSView.defersThemedToolTipTracking (opt-in;
only SidebarIconButton sets it, the 127 other themedToolTip users do
not change) keeps the area off while the view is hidden or has no
window, and themedToolTipCanShow() adds it when the button shows
(viewDidUnhide, viewDidMoveToWindow, and now viewDidMoveToSuperview,
the review's P3: a move from a hidden to a shown superview in the same
window). VoiceOver's help is set with the text, as before.

Sidebar-Look: no visual change

* test(sidebar): a new workspace row's hover controls start hidden, with no image (review P3, red)

Review of #33112 at bc142a2 (P3): the row's Unpin button was not
hidden at init, so it made its image when the row joined the window,
before the row's first layout hid it. Fails at bc142a2 on the pin.

Sidebar-Look: no visual change

* perf(sidebar): a workspace row's Unpin button starts hidden (review P3)

It shows only on hover of an expanded pinned row (layoutPin through
setHoverShown, which unhides a hidden view the same way), and the row's
first layout hid it anyway. Hidden at init, it makes no image before
the first frame, as the close, audio and tree fold buttons.

Sidebar-Look: no visual change
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Launch A/B re-run on a clean mini (CMUX20 NOISE, STATUS 10157 / 10158): no measurable first-frame gain from A2.

The A/B behind the "small gain" claim (STATUS 7567) ran on cmux20-bench while a second cmux NEXT app and its daemons ran there (STATUS 8394). The same refs and bench args, re-run on cmux19 (whole host, interleaved A / A1 / A1+A2 twice, 10 warm realistic launches per step, ms from main()):

arm n first window frame p50 p95 didFinishLaunching end -> first frame p50
A (feat d774bd5) 20 307.3 327.9 61.8
A1 (#32388, 2f1caeb) 20 303.7 351.5 61.2
A1+A2 (this PR, 7020e77) 20 303.9 348.6 60.3
  • A1+A2 vs A1: first frame p50 +0.2 ms, segment p50 -0.9 ms. That is inside the noise. The cmux20 "-3 ms" did not reproduce.
  • No regression either (p95 348.6 vs 351.5).

Corrections to the description:

  • "That removes the 37-43 ms AppKit stall in the first display pass after reopening a window": the stall stacks may have moved, but the warm launch first frame does not get faster. Please read it as "no measurable launch gain; the shared bitmap removes repeated glyph renders after launch".
  • The "Launch A/B" line still has the placeholder AB_LINE. The numbers above can fill it.

No revert: the change is correct and keeps its later-render benefit. Data: /work/wt/chrome-parity-design/mini-queue/results.jsonl (specs c20n-n32388-*), pooled with /work/tmp/cmux20-noise/pool.py c20n-n32388.

This branch was previously deployed

1 inactive deployment
ci-routing-agent — 57569f6c Deployed Nov 2, 2026 by cmux-bot[bot] via cmux-tui tree preflight #102434
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants