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

Merged
LA
4d ago
+268-32

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 #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, 37d98175 (the test on feat 82eddd7c): job 3c8c89abf8e3279852e971c9: IconRasterSharingTests FAILS: 8 bitmaps for 8 identical glyphs, at 1x, at 2x and for the SF Symbol.
  • Green, head afa4fd35: 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 dd6e954f (3 P2 fixed since) and SHIP at 0a428af2; 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.