Files
effectcraft/crates/render
08362b9c04 GPU: free effect working textures after each call instead of at frame end (#1523)
* GPU: free effect working textures after each call instead of at frame end

A GPU frame keeps its encoder open while the CPU renderer runs layers with GPU
effects (Gpu::effects -> run_chain). Each call's Enc handed its textures back
to the shared pool, where they waited for every open encoder, so all effect
intermediates of a frame stayed allocated until the frame ended (17.7 GB by
layer 17 of a heavy 4K comp, then OOM). Effect calls now borrow a pool owner
(Enc::lent) that is given back once the call's work is submitted or
discarded; its kept textures are trimmed to the pool budget, so the rest are
freed after each call.

* GPU: one pool budget for idle effect calls, trimmed at the end of a render

Review of #1523: each idle lent owner kept up to POOL_BUDGET of textures of
its own, outside the shared accounting, and there were as many idle owners as
effect calls ever ran at once (16 frame threads: ~12 GB after a 4K render).

Giving a lent owner back now moves its kept textures to the shared free list,
so one POOL_BUDGET bounds what the free list and every finished effect call
keep. They skip Pool::release's wait for open encoders: only the lent Enc
recorded work on them, and that work is submitted or discarded. Each lend is
a fresh owner id, so a lease that outlives its call (an image handed to
another encoder) is released through Pool::release; this does not rely on
the result being read back.

Accelerator::render_end (called when an export ends) drops the kept textures.

* GPU: evict the pool's oldest free textures, guard escaped leases

Review of #1523 (follow-up to 0a884fb6):

- Pool::make_free rejected a texture when the free list was full and never
  evicted, so once sizes that no longer occur (per-frame blur bounds, padded
  layer sizes) filled the budget, every later free was a drop and every take
  an allocation. It now evicts the least recently freed textures until the
  new one fits.
- A lease read by another Enc (dispatch, copy, readback, display) or handed
  to a cache is marked escaped and always released through the guarded
  Pool::release, so a lent call can no longer reuse a texture that a still
  open encoder reads.
- Docs: POOL_BUDGET caps the free list only; textures waiting for open
  encoders and those of live Encs are outside it.
- export_async calls render_end from a drop guard, so the pool is also
  trimmed when an export panics or its future is dropped.
- Tests use a private context with its own pool budget instead of a global
  override; new tests for stale sizes and an image read by an open encoder.

* GPU: evict only stale pool sizes, count evicted textures until the GPU frees them

Evicting the least recently freed texture for every newcomer thrashed when a frame
frees more than the budget in a repeating order (blur/glow intermediates): each free
evicted the size the next call takes first, so misses came back to the old level and
evict + reallocate raised the peak.

The free list now evicts only sizes nobody asked for in the last 2 output frames
(Accelerator::frame_end, called by the export per frame); otherwise the incoming
texture is dropped and the kept set keeps hitting. Evicted bytes count against the
budget until the work submitted so far is done, so evictions run at most one budget
ahead of the GPU.

* GPU: lower the shared pool budget to 256 MB

On 4K RTX 3090 runs 256 MB matches the old code's peak reserved on d_blurglow
(9204 MB) and is 256-512 MB above it on c_effects (6592 vs 6080 MB), while
staying faster.

* GPU: end frames on the multi-frame render paths, route recording through Enc::record

Only the export pipeline called frame_end, so off export (preview, remote
frames) the pool's clock stood still, no size went stale and a full free
list dropped every newcomer. passes::comp_frame (exports), remote frames
and the preview workers now end each frame; test without pool_frame_end.

Docs: the frame clock is coarse with many frames in flight; dying only
throttles eviction, approximately; no cache path escapes images. Enc::record
is the single way to get the encoder outside context.rs and marks the
images it names (Enc::uses).

* GPU: wait for the empty submission while evicted pool textures still count

stale_sizes_do_not_starve_a_steady_size failed with 0 hits and 20 misses on
hardware adapters: pool_frame_end submitted an empty batch and then only did a
non-blocking Poll, and the queue's work-done callback did not run before the
next frame, so Pool::dying never drained and make_free stopped evicting stale
sizes.

While dying is non-zero, pool_frame_end now waits on that empty submission for
up to the readback timeout (the pool lock is already released). A wait timeout
just moves on and does not retire the context or its readbacks; other poll
errors still retire them. Without dying textures it keeps the cheap Poll.

Enc::crop records through Enc::record(&[src, &dst]) so both pooled images are
registered like on every other recording path (review).

* GPU: do not repeat the pool frame-end wait until evicted textures drain

A wait that frees none of the evicted bytes (timeout, or a WebGPU poll that
does not block) marks the pool stalled; later frames only poll until dying
drops. Test covers the no-repeat and partial-progress cases; dying decrement
saturates.

* Remote frames: end a pending frame that a new render replaces

A render arriving while a multi-pass frame waits for readbacks dropped that frame without
`frame_end`, so the GPU pool's clock skipped it. Errors return before `frame_begin`.

* Render: end the frame even when a comp frame panics

`passes::comp_frame` called `frame_end` only after a normal return, so a frame that
panicked (or whose future was dropped between passes) never aged the GPU pool.

* GPU pool: log the reuse rate at render end; test reuse across effect calls and frames

Log cumulative hits/misses (and reuse %) once per render before trimming. Tests
cover reuse within an open frame across effect calls, and across repeated frames.

---------

Co-authored-by: Matej Kozlovský <MatejKozlovsky@users.noreply.github.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-03 21:12:19 +01:00
..