Skip to content

Tao: reclaim the GPU resource cache on the macOS and Linux hosts - #641

Merged
kdroidFilter merged 3 commits into
nucleus-2.6from
feat/638-tao-macos-gpu-cache-purge
Sep 6, 2026
Merged

Tao: reclaim the GPU resource cache on the macOS and Linux hosts#641
kdroidFilter merged 3 commits into
nucleus-2.6from
feat/638-tao-macos-gpu-cache-purge

Conversation

@kdroidFilter

@kdroidFilter kdroidFilter commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

First slice of #638: bring the macOS Metal host and the Linux GL host up to the level the Windows host has had since #347 / #477, and pull the shared cache policy into one place. All three hosts now run the same policy.

Step 0: the measurement that reframes the issue

Created a Metal DirectContext and read its inherited budget:

default resourceCacheLimit = 268435456 bytes = 256 MiB

Exactly the value RESOURCE_CACHE_LIMIT_BYTES writes on Windows. So that write was a no-op, and the comment on it — "an explicit budget forces purgeAsNeeded on each flush so the cache stays bounded" — was wrong: Skia purges to fit its budget whether or not we set one. What actually reclaims is the purge. The comment is corrected here rather than copied to two more platforms.

Corollary for the rest of #638: proposal 1 ("cap per surface") is not "cap the uncapped", it is "run below Skia's default" — a different and more debatable change, deliberately not made here.

What's in

scene/GpuResourceCache.kt (new) — shared policy: the limit-toggle as the only primitive skiko gives us (resourceCacheLimit and nothing else — no freeGpuResources, no purgeUnlockedResources, not even a usage read-back), its two binding properties (frees unlocked resources only; issues backend deletes so it must run where the context is usable), and the two constants. Windows' two private constants move here — same values, same behaviour.

scene/TaoComposeSceneHost.kt (macOS)

  • budget anchored inside the same runOnRenderThread hop as makeMetal — writing it purges to fit, so it belongs on the owning thread
  • purgeGpuResourceCache() submits the toggle to renderExecutor rather than awaiting it. Metal has no current context, so none of the Tao/Windows: rapid resize of secondary DecoratedWindow blanks parent window content #514 foreign-context hazard applies; the hazard here is thread affinity. Non-blocking on purpose: the caller is the Tao main thread on the resize path, and awaiting would park the drag behind the in-flight replay. FIFO puts the purge cleanly between two frames
  • purge every 250 ms while sizes stream

scene/TaoComposeSceneHostLinux.kt (Linux, EGL/GL)

  • budget anchored right after makeGLWithInterface, while the fresh context is still the one nativeAttach* left current
  • the purge is armed in onResized and performed in the render pass. Not a detail: onResized runs on the event-loop thread with no EGL context bound — the swap thread may be holding ours for its eglSwapBuffers — while the render pass is the one point where this host's context is current on this thread. It also lands right after applyPendingNativeResize closed the previous size's Surface + BackendRenderTarget, i.e. exactly when their backing memory is unlocked and the toggle has something to return
  • the Windows foreign-context hazard (Tao/Windows: rapid resize of secondary DecoratedWindow blanks parent window content #514) applies here only worse: every Linux surface owns a private, unshared context (popup layer, tray panel, sibling window), so ids collide wholesale

One deliberate divergence from Windows, on both new hosts. No settle purge and no System.gc() nudge. Windows only sees WM_EXITSIZEMOVE after a real drag; macOS and GTK have no drag-end signal at all, so a timer would have to stand in for one — and it would then fire after every size change, including the single event a zoom, a snap or a programmatic resize produces. A stop-the-world collection half a second after each of those costs more than it returns; on macOS it was also measured as a visible stall (a window with a live NativeView embed dropped below 4 frames per 400 ms right after a storm).

The Windows resize path is otherwise untouched (#477 / #514 intact).

Results, per platform

macOS — the mechanism, not the memory. The resize path is not where macOS memory sits. On tao-demo, a 60-step resize storm moves the graphics bucket 68 MB → 72 MB, and a settle purge + GC returns essentially none of it — consistent with macOS pacing resize through the display link instead of Windows' unpaced modal loop.

Linux — the resize path really is where the memory sits. A 60-step storm, then a second storm shrinking back through the same sizes (NVIDIA graphics memory for the process, GNOME / native Wayland):

before storm after storm 1 after storm 2 (shrink back)
without purge 13 MB 106 MB 114 MB
with purge 13 MB 71 MB 27 MB

Without the purge the footprint ratchets — the second storm revisits sizes the first already paid for and still grows, because nothing ever releases the scratch of a size no frame will ask for again. With it, the footprint tracks the current window instead of the high-water mark of every size ever seen. Same shape on the X11 attach path (112 MB → 23 MB).

Frame throughput is unchanged in both regimes — ~1050 frames per 1000 ms of storm on Wayland, ~93 on the vsync-paced X11 path, with and without the purge — so the reclaim costs no frames.

So the PR gives macOS parity, and gives Linux an actual reclaim. The 299 MB of #638 still lives in the at-rest triggers, which stay for the follow-ups: a purge registry + trimGpuMemory(), then wiring it to IdleGcController on feat/nucleus-optimization.

Not in scope here

macOS and Linux popup-layer / standalone-popup contexts, and every at-rest trigger.

Test plan

  • :decorated-window-tao:compileKotlin
  • :decorated-window-tao:detekt, :decorated-window-tao:ktlintMainSourceSetCheck
  • :decorated-window-tao:test
  • headful macOS: purge instrumented temporarily — 4 firings after a lone resize, 14 after a 60-step storm (periodic + settle as designed)
  • headful macOS smoke with the final code: resize storm, app alive, no SEVERE / exception
  • headful Linux: purge instrumented temporarily — 9 firings across two ~960 ms storms, i.e. the 250 ms interval exactly; A/B memory + frame numbers above, on both the Wayland and X11 attach paths
  • headful Linux taoHeadfulTest, resize-storm cases: all pass, including a texture view appearing and disappearing during a resize storm (the case that would catch a purge eating live texture imports)
  • headful Linux taoHeadfulTest, full suite: 103 run, 6 failed — the same 6 window v2 clone position-request cases fail on the base commit of this session (GDK ignores client positioning on native Wayland), so pre-existing
  • Windows: confirm the constant move left the resize path byte-identical in behaviour

Refs #638

@kdroidFilter
kdroidFilter force-pushed the feat/638-tao-macos-gpu-cache-purge branch from 9309f13 to 266d58f Compare September 4, 2026 11:20
@kdroidFilter

Copy link
Copy Markdown
Collaborator Author

CI: both remaining failures are pre-existing on nucleus-2.6

Reproduced locally on this machine (macOS, headful suite), so this is measurement rather than inference.

tao-headful (macos-latest) — "window v2 clone: rapid maximize/restore toggling then a bounds request converges"

tree result
this PR (266d58fa) FAIL 3/3, times out at 30.6 s
base nucleus-2.6 (1b86ec95) FAIL 3/3, times out at 30.6 s
pre-merge parent (bcb469ce, merge of #634) FAIL 2/2, times out at 30.6 s

The case is red on the branch that introduced it, and stays red with or without this PR. It is not #635 either — it predates that merge. Worth its own issue: timed out waiting for: final bounds applied after the toggling storm, WindowApiV2HeadfulCases.rapidPlacementTogglingThenBoundsConverges.

tao-headful (ubuntu-latest) — "satellite placement opened over a mapped parent, never seen away from its anchor"

This PR has no behavioural effect off macOS — GpuResourceCache.kt is constants only, and the Windows host changed by a constant rename and a comment. The same assertion (case premise: the satellite did not end up off the parent's right edge) also fired on feat/tao-satellite-window's own run 33823405675 before that branch went green, alongside several other workspace cases. Flaky under CI load, from #635.

GraalVM ×3 failed on the first run and passed unchanged on the second — flakes, ignore.

Correction to the previous force-push

The first run's macOS failure was "an embedded native view survives a resize storm", and I attributed it to the settle purge landing inside that case's frame-counting window. That attribution was wrong: the same case failed on feat/tao-satellite-window's run 33823405675, on a tree without this PR.

Removing the settle purge stands, but on its own merits, not on CI's: the footprint measurement above (68 MB → 72 MB across a storm, ~nothing returned by purge + GC) is the reason, and the commit message has been amended to say so.

Net: this PR does not break anything. The base branch is red.

@kdroidFilter

Copy link
Copy Markdown
Collaborator Author

Correction: the v2 maximize case is flaky, not hard-broken

My previous comment read the 3/3 local failures as "red on nucleus-2.6 since #634". That was too strong — the case is non-deterministic, and the evidence is in this PR's own CI:

when tree result
2026-09-02, CI macos-26-arm64 feat/compose-window-api-v2 PASS, 1250 ms
2026-09-04 07:50, CI, this PR run #1 this PR (b4950278) PASS, 1390 ms
2026-09-04 10:10, CI, this PR run #2 this PR (9309f13d) FAIL, 30.6 s timeout

So it passed twice on CI, once on a tree containing this PR, and failed once. It does not discriminate between trees.

Locally it is a different story: on my machine it fails 100% — 3/3 on this PR, 3/3 on nucleus-2.6, 2/2 on the pre-merge parent bcb469ce, 2/2 on feat/compose-window-api-v2's head. Same OS version as the runner (macOS 26, unchanged since June), same tree that CI passes. That points at a machine-local condition (display/WindowServer state) the case is sensitive to, not at any commit.

Scope note: the case only exists on nucleus-2.6 and its feature branch — WindowApiV2HeadfulCases.kt is absent from main and nucleus-2.5, since the v2 clone is a 2.6 feature. So "2.6-only" is true of the test, not of a regression.

Still worth its own issue — a case that is 100% red on one machine and green on another is hiding a real ordering/timing assumption — but it is unrelated to this PR, and unrelated to #635.

The tao-headful (ubuntu-latest) satellite failure and the GraalVM ×3 assessment in the previous comment are unchanged.

The Metal and GL scene hosts never purged Skia's GPU resource cache;
only the Windows host did, off the resize path (#347, #477). This brings
macOS up to that level and pulls the shared policy into one file.

Measured first: Ganesh already hands out 256 MiB by default, exactly the
value the Windows host writes at attach. So that write was a no-op and
the comment claiming it "forces purgeAsNeeded on every flush ... which is
what keeps the steady state bounded at all" was wrong — Skia purges to
fit its budget whether or not we set one. What reclaims is the purge.
The comment is corrected rather than propagated.

macOS gets the same mechanism, adapted to the backend:

  - the budget is anchored inside the same runOnRenderThread hop as
    makeMetal, since writing it purges to fit and so belongs on the
    owning thread;
  - purgeGpuResourceCache() submits the limit-toggle to the render
    executor instead of awaiting it — Metal has no current context, so
    none of the #514 foreign-context hazard applies here, but the
    context is thread-affine, and blocking the Tao main thread would
    park the drag behind the in-flight replay;
  - onResizeStreamAdvanced() purges every 250 ms while sizes stream and
    once more 500 ms after the last one, standing in for the
    WM_EXITSIZEMOVE macOS never sends.

One deliberate divergence from Windows: the settle's System.gc() is
gated on the burst having carried at least 8 resize events. Windows only
sees WM_EXITSIZEMOVE after a real drag, but here every size change
settles, including the single event a zoom, a snap or a programmatic
resize produces, and a stop-the-world collection after each of those
costs more than it returns.

The Windows resize path is untouched beyond the constant move and the
comment fix.
The settle pair (a 500 ms timer standing in for the WM_EXITSIZEMOVE macOS
never sends, then a purge and a System.gc()) does not pay for itself on
this backend.

macOS paces its resize frames through the display link, so it never
accumulates the way Windows' unpaced modal loop does: a 60-step resize
storm on tao-demo moved the graphics footprint 68 MB -> 72 MB, and a
purge + GC at the end of it returned essentially none of that. Against
that nil benefit sits a real cost — a full cache purge re-mints the glyph
atlas and layer backings, and the GC is stop-the-world, both landing half
a second after every resize, including the single event a zoom, a snap or
a programmatic resize produces.

Keep the in-drag periodic purge, which is free (every frame of a drag is
re-rastering anyway) and bounds a long drag on a large display. Drop the
settle timer, the GC nudge and the burst-count gate that tried to make
the nudge affordable; with them go the two constants and the host scope
they needed, so startRenderLoop goes back to its local scope.

The reclaim #638 actually wants is at rest, not at drag end, and belongs
on the idle path.
@kdroidFilter
kdroidFilter force-pushed the feat/638-tao-macos-gpu-cache-purge branch from 266d58f to 888ab6c Compare September 4, 2026 12:26
Third and last host of the shared policy from GpuResourceCache: the EGL
host now anchors the same budget at attach and purges the per-size
scratch periodically while resize events stream, like Windows inside the
modal resize/move loop and macOS on the display-link-paced one.

The purge is armed in onResized but performed in the render pass. That
is not a detail: onResized runs on the event-loop thread with no EGL
context bound and the swap thread may be holding ours for its
eglSwapBuffers, while the render pass is the one point where this host's
context is current on this thread. It also lands right after
applyPendingNativeResize has closed the previous size's Surface and
BackendRenderTarget, which is exactly when their backing memory is
unlocked and the toggle has something to return.

Unlike macOS, the Linux resize path IS where the memory sits. On a
60-step storm, then a second storm shrinking back through the same
sizes (NVIDIA graphics memory for the process, native Wayland / GNOME):

    without purge   13 MB -> 106 MB -> 114 MB
    with purge      13 MB ->  71 MB ->  27 MB

Without the purge the footprint ratchets: the second storm revisits
sizes the first already paid for and still grows, because nothing ever
releases the scratch of a size no frame will ask for again. With it the
footprint tracks the current window instead of the high-water mark of
every size ever seen. Same shape on the X11 attach path (112 MB -> 23
MB). Frame throughput is unchanged in both regimes (~1050 frames per
1000 ms on Wayland, ~93 on the vsync-paced X11 path), so the reclaim
costs no frames.

No settle purge and no System.gc() nudge, for the reason macOS has
none: GTK offers no drag-end signal, and a timer standing in for one
buys a stop-the-world collection after every zoom, snap and
programmatic resize.

Refs #638
@kdroidFilter kdroidFilter changed the title Tao: reclaim the GPU resource cache on the macOS Metal host Tao: reclaim the GPU resource cache on the macOS and Linux hosts Sep 6, 2026
@kdroidFilter
kdroidFilter marked this pull request as ready for review September 6, 2026 12:53
@kdroidFilter
kdroidFilter merged commit c774856 into nucleus-2.6 Sep 6, 2026
17 of 23 checks passed
@kdroidFilter
kdroidFilter deleted the feat/638-tao-macos-gpu-cache-purge branch September 6, 2026 13:50
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