From 30b9c8a032dd4864f5546c2708c16b9d979ad4eb Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Fri, 4 Sep 2026 10:49:29 +0300 Subject: [PATCH 1/3] feat(tao): reclaim the GPU resource cache on the macOS Metal host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../window/tao/scene/GpuResourceCache.kt | 68 ++++++++++ .../window/tao/scene/TaoComposeSceneHost.kt | 121 ++++++++++++++++-- .../tao/scene/TaoComposeSceneHostWindows.kt | 49 +++---- 3 files changed, 197 insertions(+), 41 deletions(-) create mode 100644 decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/GpuResourceCache.kt diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/GpuResourceCache.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/GpuResourceCache.kt new file mode 100644 index 000000000..d3ebd934b --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/GpuResourceCache.kt @@ -0,0 +1,68 @@ +package dev.nucleusframework.window.tao.scene + +/* + * Shared policy for the Skia GPU resource cache of the scene hosts' + * `DirectContext`s. + * + * Skia evicts only when a new allocation would push the cache past its budget, + * so a scene that stops drawing keeps its high-water mark for the rest of the + * process' life. `DirectContext` offers no purge of its own — skiko exposes + * `resourceCacheLimit` and nothing else: no `freeGpuResources`, no + * `purgeUnlockedResources`, not even a usage read-back — so the only primitive + * available to us is *toggling the limit*. Writing 0 runs Skia's + * `purgeAsNeeded` inline, releasing every unlocked resource; writing the budget + * back lets the next frame re-mint only what it actually needs. + * + * Two properties of that primitive shape every caller: + * + * - It frees **unlocked** resources only. Compose layers and pictures still + * referenced by live Java objects keep their Skia natives locked, and those + * are released by the skiko `Cleaner` only after a GC — which is why the + * settle paths pair the purge with a `System.gc()` nudge, and why a purge + * alone never returns a drag's or an animation's full peak. + * - It issues backend deletes, so it must run where the context is usable: + * with *that* host's GL context current on the ANGLE/EGL hosts (purging + * against a sibling's binding deletes ids in the sibling's namespace — see + * the KDoc on `TaoComposeSceneHostWindows.purgeGpuResourceCache`), and on + * the owning render thread on Metal, where the context is thread-affine. + */ + +/** + * Budget written onto a host `DirectContext` at attach. + * + * Measured, not assumed: Ganesh already hands out exactly 268435456 bytes by + * default, so at the current value this write is a deliberate no-op. It is the + * explicit anchor the limit-toggle purge restores, and the single place to + * change should we ever decide to run the hosts *below* Skia's own default + * (which is the interesting question once several surfaces each own a context). + * Do not read it as "the cache would be unbounded without this line". + */ +internal const val GPU_RESOURCE_CACHE_LIMIT_BYTES: Long = 256L * 1024 * 1024 + +/** + * Gap between in-drag purges while resize events are streaming. Every frame of + * a drag mints render-target scratch (stencil/attachments) at a size no later + * frame reuses; purging periodically releases that accumulation mid-drag so the + * peak stays bounded even on long drags, without skipping a resize frame (a + * skipped frame is composited as a geometry/content mismatch — trembling). + */ +internal const val GPU_RESIZE_PURGE_INTERVAL_NS: Long = 250_000_000L + +/** + * Quiet period after the last resize event, standing in for a drag-end signal + * on backends that have none. Windows is told exactly when the drag ends + * (`WM_EXITSIZEMOVE`); AppKit's `viewDidEndLiveResize` is not bridged through + * the Metal helper, so the macOS host settles on a timer instead. Long enough + * that a human pausing mid-drag rarely pays the re-raster of a full purge, + * short enough that the drag's dead scratch does not stay resident. + */ +internal const val GPU_RESIZE_SETTLE_MS: Long = 500L + +/** + * Resize events a burst must have carried before its settle is allowed to nudge + * a `System.gc()`. A border drag streams dozens; a zoom, a snap, a display hop + * or a programmatic resize streams one or two, and those must not each buy a + * stop-the-world collection. Only the hosts without a real drag-end signal need + * this — Windows is told when the drag ends and can nudge unconditionally. + */ +internal const val GPU_RESIZE_GC_MIN_EVENTS: Int = 8 diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt index bc52b3843..3e683b52b 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt @@ -52,9 +52,12 @@ import dev.nucleusframework.window.tao.render.LocalTaoTextSelectionA11yPublisher import dev.nucleusframework.window.tao.render.TaoSelectionAccessibilityObserver import dev.nucleusframework.window.tao.shouldApplyLargeCornerRadius import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -356,8 +359,18 @@ internal class TaoComposeSceneHost( val devicePtr = NativeMetalBridge.nativeDevicePtr(handle) val queuePtr = NativeMetalBridge.nativeQueuePtr(handle) // The Skia Metal DirectContext is thread-affine: create it on the render - // thread that will use it for every frame's GPU encode + present. - directContext = runOnRenderThread { DirectContext.makeMetal(devicePtr, queuePtr) } + // thread that will use it for every frame's GPU encode + present. The + // resource-cache budget is anchored in the same hop — writing it purges + // to fit, so it belongs on the owning thread like every other use of + // the context. See GPU_RESOURCE_CACHE_LIMIT_BYTES for why the value + // itself changes nothing today, and [purgeGpuResourceCache] for what + // actually reclaims. + directContext = + runOnRenderThread { + DirectContext.makeMetal(devicePtr, queuePtr).also { + it.resourceCacheLimit = GPU_RESOURCE_CACHE_LIMIT_BYTES + } + } scale = initialMacOsScaleFactor(window) @@ -685,6 +698,87 @@ internal class TaoComposeSceneHost( scene?.size = IntSize(widthPx, heightPx) updateWindowInfoSize() window.requestRedraw() + onResizeStreamAdvanced() + } + + private var lastResizePurgeNs: Long = 0 + private var resizeSettleJob: Job? = null + private var resizeBurstEvents: Int = 0 + + /** + * Reclaims the per-size GPU scratch a live resize mints — the macOS half of + * what [TaoComposeSceneHostWindows.onResizeLoopChanged] does for the OS + * modal resize/move loop. + * + * Two purges, for the two halves of a drag. The periodic one keeps a long + * drag's peak bounded while sizes are still streaming (Skia's budget caps + * the cache, but a capped cache full of scratch no frame will ever ask for + * again is still 256 MiB resident). The settle one stands in for the + * `WM_EXITSIZEMOVE` macOS never sends us: [GPU_RESIZE_SETTLE_MS] after the + * last size, the drag is over for all practical purposes, so drop what it + * accumulated and nudge one GC so the skiko `Cleaner` can release the + * Compose layers/pictures every remeasure minted — the purge cannot touch + * those while they are still locked, and a settled scene allocates nothing, + * so no collection would otherwise come on its own. + * + * Re-armed on every resize, so a continuous drag only ever pays the + * periodic purge; the expensive pair lands once, after the user lets go. + * + * The GC half is gated on the burst having been a real drag + * ([GPU_RESIZE_GC_MIN_EVENTS]). Windows can be unconditional because + * `WM_EXITSIZEMOVE` only arrives after one; here every size change settles, + * including the single event a zoom, a snap or a programmatic resize + * produces — and a stop-the-world collection half a second after every such + * resize costs far more than the handful of layers one of them minted. + */ + private fun onResizeStreamAdvanced() { + val now = System.nanoTime() + resizeBurstEvents++ + if (now - lastResizePurgeNs >= GPU_RESIZE_PURGE_INTERVAL_NS) { + lastResizePurgeNs = now + purgeGpuResourceCache() + } + resizeSettleJob?.cancel() + resizeSettleJob = + hostScope.launch { + delay(GPU_RESIZE_SETTLE_MS) + val wasDrag = resizeBurstEvents >= GPU_RESIZE_GC_MIN_EVENTS + resizeBurstEvents = 0 + purgeGpuResourceCache() + if (wasDrag) { + @Suppress("ExplicitGarbageCollectionCall") + System.gc() + } + } + } + + /** + * Frees the GPU resource cache: toggling the limit to 0 runs Skia's + * `purgeAsNeeded` inline, releasing every unlocked resource, and restoring + * the budget lets the next frame re-mint only what it needs. The only purge + * primitive skiko exposes — see [GPU_RESOURCE_CACHE_LIMIT_BYTES]. + * + * Metal has no notion of a *current* context, so none of the foreign-context + * hazard the ANGLE/EGL hosts guard against (#514) applies here: the danger + * on this backend is thread affinity instead. The `DirectContext` is created + * on, and only ever touched from, [renderExecutor], so the toggle hops + * there — submitted rather than awaited, because the caller is the Tao main + * thread on the resize path and blocking it would park the drag behind the + * in-flight replay. FIFO ordering puts the purge cleanly between two frames, + * where nothing the host caches is live (each frame wraps the drawable's + * texture in a fresh `BackendRenderTarget`), and once [detach] has nulled + * the context this returns before submitting anything. + */ + private fun purgeGpuResourceCache() { + val ctx = directContext ?: return + // Rejected once detach() shut the executor down; a purge is never worth + // routing to the fatal handler. + runCatching { + renderExecutor.submit { + ctx.resourceCacheLimit = 0 + ctx.resourceCacheLimit = GPU_RESOURCE_CACHE_LIMIT_BYTES + } + } } /** @@ -1385,6 +1479,17 @@ internal class TaoComposeSceneHost( private var frameDispatcher: org.jetbrains.skiko.FrameDispatcher? = null private val renderLoopJob = kotlinx.coroutines.SupervisorJob() + /** + * Main-thread scope for the host's own deferred work (today: the resize + * settle in [onResizeStreamAdvanced]). Shares [renderLoopJob], so + * [detach]'s cancel takes it down with the render loop and nothing can fire + * against a torn-down host. + */ + private val hostScope = + CoroutineScope( + coroutineContext + TaoMainDispatcher + renderLoopJob + TaoFatalCoroutineExceptionHandler, + ) + /** Schedules a single coalesced frame on the render loop. The sole entry * point for "please repaint" — both Compose `invalidate` and Tao * `RedrawRequested` events funnel through here so frames stay serialized. */ @@ -1401,14 +1506,12 @@ internal class TaoComposeSceneHost( private fun startRenderLoop(handle: Long) { // FrameDispatcher runs ONE long-lived coroutine: an exception in a // frame kills it for good, and the SupervisorJob would swallow the - // failure — the window silently stops repainting (#622). Route it to - // the fatal path instead (SEVERE log, native dialog, clean exit). - val scope = - kotlinx.coroutines.CoroutineScope( - coroutineContext + TaoMainDispatcher + renderLoopJob + TaoFatalCoroutineExceptionHandler, - ) + // failure — the window silently stops repainting (#622). [hostScope] + // carries TaoFatalCoroutineExceptionHandler for exactly that: the + // failure takes the fatal path (SEVERE log, native dialog, clean exit) + // instead of being dropped. frameDispatcher = - org.jetbrains.skiko.FrameDispatcher(scope) { + org.jetbrains.skiko.FrameDispatcher(hostScope) { renderFrameSuspending(handle) } } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt index ef8cb3420..ee482919d 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt @@ -369,15 +369,21 @@ internal class TaoComposeSceneHostWindows( attachmentHandle = handle directContext = (ctx ?: error("Failed to create Skia DirectContext on the ANGLE ES context")).also { - // Bound the GPU resource cache. Each frame wraps the default - // framebuffer in a fresh BackendRenderTarget + Surface, and Skia - // allocates a stencil/scratch attachment sized to the current - // window for it. During a border drag every new window size mints - // new scratch resources; even with VSync pacing the present (see - // onResizeLoopChanged) an explicit budget forces purgeAsNeeded on - // each flush so the cache stays bounded, and onResizeLoopChanged - // additionally purges the scratch accumulated across the drag. - it.resourceCacheLimit = RESOURCE_CACHE_LIMIT_BYTES + // Anchor the GPU resource cache budget. Each frame wraps the + // default framebuffer in a fresh BackendRenderTarget + Surface, + // and Skia allocates a stencil/scratch attachment sized to the + // current window for it; during a border drag every new window + // size mints scratch no later frame reuses. + // + // This write is a no-op at the current value — Ganesh's own + // default is the same 256 MiB (measured) — and it does NOT, as + // this comment used to claim, "force purgeAsNeeded on each + // flush": Skia purges to fit its budget whether or not we set + // one. What actually reclaims the drag's scratch is the purge, + // in onResized and onResizeLoopChanged. Keep the write anyway: + // it is the value the limit-toggle restores and the one place + // to change if the hosts ever run below Skia's default. + it.resourceCacheLimit = GPU_RESOURCE_CACHE_LIMIT_BYTES } attachedHostCount.incrementAndGet() @@ -1059,7 +1065,7 @@ internal class TaoComposeSceneHostWindows( // the drag-end path in onResizeLoopChanged reclaims the rest. if (resizeLoopActive) { val now = System.nanoTime() - if (now - lastResizePurgeNs >= RESIZE_PURGE_INTERVAL_NS) { + if (now - lastResizePurgeNs >= GPU_RESIZE_PURGE_INTERVAL_NS) { lastResizePurgeNs = now purgeGpuResourceCache() } @@ -1091,7 +1097,7 @@ internal class TaoComposeSceneHostWindows( val ctx = directContext ?: return if (attachmentHandle != 0L) NativeTaoGlBridge.nativeMakeCurrent(attachmentHandle) ctx.resourceCacheLimit = 0 - ctx.resourceCacheLimit = RESOURCE_CACHE_LIMIT_BYTES + ctx.resourceCacheLimit = GPU_RESOURCE_CACHE_LIMIT_BYTES } /** @@ -2042,27 +2048,6 @@ internal class TaoComposeSceneHostWindows( /** Half-distance of the synthetic two-finger pair at scale 1.0. */ private const val PINCH_BASE_RADIUS_PX: Float = 120f - /** - * GPU resource cache budget for the host DirectContext. Bounds the - * per-frame scratch (wrapped-framebuffer stencil/attachments) so an - * uncapped resize flood — VSync is dropped during the OS modal - * resize/move loop — can't grow the process unbounded. Sized to cover - * a HiDPI window's render target plus Compose's layer/glyph caches - * with headroom, while still far below the >1 GB the leak reached. - */ - private const val RESOURCE_CACHE_LIMIT_BYTES: Long = 256L * 1024 * 1024 - - /** - * Gap between in-drag GPU cache purges during the OS modal - * resize/move loop. Every frame of the drag mints render-target - * scratch (stencil/attachments) at a size no later frame reuses; - * the periodic limit-toggle purge in [onResized] releases that - * accumulation mid-drag so the peak stays bounded even for long - * drags, without skipping any resize frame (a skipped frame is - * composited by DWM as a geometry/content mismatch — trembling). - */ - private const val RESIZE_PURGE_INTERVAL_NS: Long = 250_000_000L - // Stable ids well clear of real touch ids (raw WM_POINTER finger ids). private const val PINCH_POINTER_ID_A: Long = 0xA001L private const val PINCH_POINTER_ID_B: Long = 0xA002L From 888ab6c7a2be165e6c3b763f5d12670c0a1c54e3 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Fri, 4 Sep 2026 13:09:58 +0300 Subject: [PATCH 2/3] fix(tao): drop the macOS resize settle purge, keep the in-drag one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../window/tao/scene/GpuResourceCache.kt | 22 +---- .../window/tao/scene/TaoComposeSceneHost.kt | 91 ++++++------------- 2 files changed, 33 insertions(+), 80 deletions(-) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/GpuResourceCache.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/GpuResourceCache.kt index d3ebd934b..09ac1cf12 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/GpuResourceCache.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/GpuResourceCache.kt @@ -48,21 +48,9 @@ internal const val GPU_RESOURCE_CACHE_LIMIT_BYTES: Long = 256L * 1024 * 1024 */ internal const val GPU_RESIZE_PURGE_INTERVAL_NS: Long = 250_000_000L -/** - * Quiet period after the last resize event, standing in for a drag-end signal - * on backends that have none. Windows is told exactly when the drag ends - * (`WM_EXITSIZEMOVE`); AppKit's `viewDidEndLiveResize` is not bridged through - * the Metal helper, so the macOS host settles on a timer instead. Long enough - * that a human pausing mid-drag rarely pays the re-raster of a full purge, - * short enough that the drag's dead scratch does not stay resident. - */ -internal const val GPU_RESIZE_SETTLE_MS: Long = 500L - -/** - * Resize events a burst must have carried before its settle is allowed to nudge - * a `System.gc()`. A border drag streams dozens; a zoom, a snap, a display hop - * or a programmatic resize streams one or two, and those must not each buy a - * stop-the-world collection. Only the hosts without a real drag-end signal need - * this — Windows is told when the drag ends and can nudge unconditionally. +/* + * There is deliberately no "settle" constant here. A drag-end purge needs a + * drag-end signal, and only Windows has one (`WM_EXITSIZEMOVE`); standing a + * timer in for it on the other hosts was measured to be a bad trade — see + * `TaoComposeSceneHost.purgeResizeScratchIfDue`. */ -internal const val GPU_RESIZE_GC_MIN_EVENTS: Int = 8 diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt index 3e683b52b..b47cba80a 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt @@ -52,12 +52,9 @@ import dev.nucleusframework.window.tao.render.LocalTaoTextSelectionA11yPublisher import dev.nucleusframework.window.tao.render.TaoSelectionAccessibilityObserver import dev.nucleusframework.window.tao.shouldApplyLargeCornerRadius import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -698,58 +695,35 @@ internal class TaoComposeSceneHost( scene?.size = IntSize(widthPx, heightPx) updateWindowInfoSize() window.requestRedraw() - onResizeStreamAdvanced() + purgeResizeScratchIfDue() } private var lastResizePurgeNs: Long = 0 - private var resizeSettleJob: Job? = null - private var resizeBurstEvents: Int = 0 /** - * Reclaims the per-size GPU scratch a live resize mints — the macOS half of - * what [TaoComposeSceneHostWindows.onResizeLoopChanged] does for the OS - * modal resize/move loop. + * Reclaims the per-size GPU scratch a live resize mints, while the sizes are + * still streaming — the macOS half of what + * [TaoComposeSceneHostWindows.onResized] does inside the OS modal + * resize/move loop. Skia's budget caps the cache, but a capped cache full of + * scratch no frame will ever ask for again is still 256 MiB resident. * - * Two purges, for the two halves of a drag. The periodic one keeps a long - * drag's peak bounded while sizes are still streaming (Skia's budget caps - * the cache, but a capped cache full of scratch no frame will ever ask for - * again is still 256 MiB resident). The settle one stands in for the - * `WM_EXITSIZEMOVE` macOS never sends us: [GPU_RESIZE_SETTLE_MS] after the - * last size, the drag is over for all practical purposes, so drop what it - * accumulated and nudge one GC so the skiko `Cleaner` can release the - * Compose layers/pictures every remeasure minted — the purge cannot touch - * those while they are still locked, and a settled scene allocates nothing, - * so no collection would otherwise come on its own. - * - * Re-armed on every resize, so a continuous drag only ever pays the - * periodic purge; the expensive pair lands once, after the user lets go. - * - * The GC half is gated on the burst having been a real drag - * ([GPU_RESIZE_GC_MIN_EVENTS]). Windows can be unconditional because - * `WM_EXITSIZEMOVE` only arrives after one; here every size change settles, - * including the single event a zoom, a snap or a programmatic resize - * produces — and a stop-the-world collection half a second after every such - * resize costs far more than the handful of layers one of them minted. + * Deliberately only the *in-drag* half of the Windows behaviour. There is no + * settle purge and no `System.gc()` nudge here, because macOS has no + * `WM_EXITSIZEMOVE` to hang them on and a timer standing in for it proved a + * bad trade twice over: the drag's own frames are display-link paced, so + * macOS never accumulates the way Windows' unpaced modal loop does (a + * 60-step storm moved the graphics footprint 68 MB → 72 MB, and a purge + GC + * at the end of it returned essentially none of that), while the pair landed + * on an animating window as a visible stall — a window with a live + * `NativeView` embed dropped below 4 frames per 400 ms right after a storm. + * Cost with no measured benefit. The reclaim that #638 is actually after is + * at rest, not at drag end, and belongs on the idle path. */ - private fun onResizeStreamAdvanced() { + private fun purgeResizeScratchIfDue() { val now = System.nanoTime() - resizeBurstEvents++ - if (now - lastResizePurgeNs >= GPU_RESIZE_PURGE_INTERVAL_NS) { - lastResizePurgeNs = now - purgeGpuResourceCache() - } - resizeSettleJob?.cancel() - resizeSettleJob = - hostScope.launch { - delay(GPU_RESIZE_SETTLE_MS) - val wasDrag = resizeBurstEvents >= GPU_RESIZE_GC_MIN_EVENTS - resizeBurstEvents = 0 - purgeGpuResourceCache() - if (wasDrag) { - @Suppress("ExplicitGarbageCollectionCall") - System.gc() - } - } + if (now - lastResizePurgeNs < GPU_RESIZE_PURGE_INTERVAL_NS) return + lastResizePurgeNs = now + purgeGpuResourceCache() } /** @@ -1479,17 +1453,6 @@ internal class TaoComposeSceneHost( private var frameDispatcher: org.jetbrains.skiko.FrameDispatcher? = null private val renderLoopJob = kotlinx.coroutines.SupervisorJob() - /** - * Main-thread scope for the host's own deferred work (today: the resize - * settle in [onResizeStreamAdvanced]). Shares [renderLoopJob], so - * [detach]'s cancel takes it down with the render loop and nothing can fire - * against a torn-down host. - */ - private val hostScope = - CoroutineScope( - coroutineContext + TaoMainDispatcher + renderLoopJob + TaoFatalCoroutineExceptionHandler, - ) - /** Schedules a single coalesced frame on the render loop. The sole entry * point for "please repaint" — both Compose `invalidate` and Tao * `RedrawRequested` events funnel through here so frames stay serialized. */ @@ -1506,12 +1469,14 @@ internal class TaoComposeSceneHost( private fun startRenderLoop(handle: Long) { // FrameDispatcher runs ONE long-lived coroutine: an exception in a // frame kills it for good, and the SupervisorJob would swallow the - // failure — the window silently stops repainting (#622). [hostScope] - // carries TaoFatalCoroutineExceptionHandler for exactly that: the - // failure takes the fatal path (SEVERE log, native dialog, clean exit) - // instead of being dropped. + // failure — the window silently stops repainting (#622). Route it to + // the fatal path instead (SEVERE log, native dialog, clean exit). + val scope = + kotlinx.coroutines.CoroutineScope( + coroutineContext + TaoMainDispatcher + renderLoopJob + TaoFatalCoroutineExceptionHandler, + ) frameDispatcher = - org.jetbrains.skiko.FrameDispatcher(hostScope) { + org.jetbrains.skiko.FrameDispatcher(scope) { renderFrameSuspending(handle) } } From 4d432e5f50dad668f3baa663bdc8279c316959ba Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 6 Sep 2026 12:50:48 +0300 Subject: [PATCH 3/3] feat(tao): reclaim the GPU resource cache on the Linux GL host 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 --- .../tao/scene/TaoComposeSceneHostLinux.kt | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt index 815de2539..eef25800a 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt @@ -383,6 +383,16 @@ internal class TaoComposeSceneHostLinux( private val postResizeCatchUpFrames = AtomicInteger(0) private val sceneSizeUpdateIntervalNs = 16_666_667L // 60fps + /** + * In-drag GPU cache purge, deferred to the next render pass. [onResized] + * runs on the event-loop thread with no EGL context bound — the swap thread + * may even hold ours for its `eglSwapBuffers` — so the timing decision is + * taken here and the purge itself happens in [onRedrawRequested], the one + * place this host's context is current on this thread. + */ + private var lastResizePurgeNs: Long = 0L + private var resizePurgeDue: Boolean = false + private var lastPointerX: Float = 0f private var lastPointerY: Float = 0f @@ -671,6 +681,13 @@ internal class TaoComposeSceneHostLinux( } val iface = GLAssembledInterface.createFromNativePointers(0L, fnPtr) val ctx = DirectContext.makeGLWithInterface(iface) + // Anchor the GPU resource cache budget while the fresh EGL context is + // still the one the native attach left current — writing the limit + // purges to fit, so like every other use of the context it belongs + // where the context is usable. The value itself changes nothing today + // (see GPU_RESOURCE_CACHE_LIMIT_BYTES); what reclaims the per-size + // scratch of a drag is [purgeResizeScratchIfDue]. + ctx.resourceCacheLimit = GPU_RESOURCE_CACHE_LIMIT_BYTES directContext = ctx // Publish the TextureView handle for the fresh EGL context / Skia // context pair (see glTextureHostState). @@ -1378,6 +1395,12 @@ internal class TaoComposeSceneHostLinux( (widthPx / opaqueScale).coerceAtLeast(1), (heightPx / opaqueScale).coerceAtLeast(1), ) + // Arm the periodic in-drag purge of the per-size GPU scratch — see + // [resizePurgeDue] for why it can't run right here. + if (now - lastResizePurgeNs >= GPU_RESIZE_PURGE_INTERVAL_NS) { + lastResizePurgeNs = now + resizePurgeDue = true + } requestRedrawCoalesced() } @@ -1539,6 +1562,45 @@ internal class TaoComposeSceneHostLinux( lastAppliedScale = scale } + /** + * Reclaims the per-size GPU scratch a live resize mints, while the sizes + * are still streaming — the Linux half of what + * [TaoComposeSceneHostWindows.onResized] does inside the OS modal + * resize/move loop. Toggling the limit to 0 runs Skia's `purgeAsNeeded` + * inline, releasing every unlocked resource; restoring the budget lets the + * next frame re-mint only what it needs. The only purge primitive skiko + * exposes — see [GPU_RESOURCE_CACHE_LIMIT_BYTES]. + * + * Called from the render pass, right after [applyPendingNativeResize] has + * closed the [cachedSurface]/[cachedRt] of the previous size: their backing + * render target and stencil are unlocked at exactly this point, so this is + * where the toggle actually returns memory rather than merely walking the + * cache. It is also the only point where this host's EGL context is current + * on this thread — the purge issues `glDelete*`, and the same foreign-context + * hazard the Windows host documents on its own purge applies here, only + * worse: every Linux surface owns a *private*, unshared context (a popup + * layer, a tray panel, a sibling window), so ids collide wholesale and a + * purge against the wrong binding deletes a sibling's live textures. + * Binding from [onResized] instead would be both racy (the swap thread may + * hold our context) and pointless, since the frame that follows re-binds + * anyway. + * + * Deliberately only the *in-drag* half of the Windows behaviour: there is + * no settle purge and no `System.gc()` nudge, for the same reason macOS has + * none (see [TaoComposeSceneHost.purgeResizeScratchIfDue]). GTK gives us no + * drag-end signal to hang them on — the compositor-driven resize grab ends + * with nothing more than pointer events resuming — and a timer standing in + * for it buys a stop-the-world collection after every zoom, snap and + * programmatic resize. The reclaim #638 is really after is at rest, not at + * drag end. + */ + private fun purgeResizeScratchIfDue(ctx: DirectContext) { + if (!resizePurgeDue) return + resizePurgeDue = false + ctx.resourceCacheLimit = 0 + ctx.resourceCacheLimit = GPU_RESOURCE_CACHE_LIMIT_BYTES + } + /** * KWin only: after a present, the pending `wl_egl_window_resize` is in * effect — advance the paint size and re-arm a frame if still behind. @@ -1671,6 +1733,7 @@ internal class TaoComposeSceneHostLinux( // Coalesced size/scale change is committed here, after the GL context // is current — applyPendingNativeResize closes the stale Skia cache. applyPendingNativeResize() + purgeResizeScratchIfDue(ctx) updateResizeBurstSwapInterval() val paintSize = resolvePaintSize()