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..09ac1cf12 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/GpuResourceCache.kt @@ -0,0 +1,56 @@ +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 + +/* + * 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`. + */ 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..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 @@ -356,8 +356,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 +695,64 @@ internal class TaoComposeSceneHost( scene?.size = IntSize(widthPx, heightPx) updateWindowInfoSize() window.requestRedraw() + purgeResizeScratchIfDue() + } + + private var lastResizePurgeNs: Long = 0 + + /** + * 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. + * + * 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 purgeResizeScratchIfDue() { + val now = System.nanoTime() + if (now - lastResizePurgeNs < GPU_RESIZE_PURGE_INTERVAL_NS) return + lastResizePurgeNs = now + purgeGpuResourceCache() + } + + /** + * 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 + } + } } /** 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() 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