Skip to content

GPU: free effect working textures after each call instead of at frame end - #1523

Open
MatejKozlovsky wants to merge 6 commits into
storytold:mainfrom
MatejKozlovsky:fx-owner
Open

MatejKozlovsky wants to merge 6 commits into
storytold:mainfrom
MatejKozlovsky:fx-owner

Conversation

@MatejKozlovsky

@MatejKozlovsky MatejKozlovsky commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Effect working textures are now freed after each Gpu::effects call instead of being kept until the frame ends, and idle pool owners no longer keep their own sets: unused textures go to one shared free list capped at 256 MiB (POOL_BUDGET).

Scope of the budget: 256 MiB caps the shared free list of unused textures only. It does not cap total live GPU memory or driver/allocator-reserved memory; textures held by live Encs and textures waiting on open encoders are outside it.

Current results (comment 6223542652, 4K, old = 13a49c9 (this PR's first commit) vs new = f029766, the same patches as the current head 73312c2) are mixed on peak memory, not a universal memory reduction:

  • RTX 3090 24 GB / Ryzen 7 5800X: d_blurglow wall 277.8–280.7 s → 245.1 s (about 12% faster), peak reserved 9204 → 8948–9204 MB. c_effects wall 46.6–52.4 s → 47.1 s, peak reserved 6080 → 6336–6592 MB (256–512 MB higher).
  • GTX 1070 8 GB / i7-7700HQ: neutral. c_effects 84.7 → 84.8 s, peak reserved 6016 → 6272–6448 MB; d_blurglow 557.1 → 558.9 s, peak reserved 8149 MB on both (the card is full).
  • Output md5 is identical between old and new in every run.

Problem

On heavy 4K comps the GPU runs out of memory on frame 0 (RTX 3090, 24 GB), and every frame then renders on the CPU. A per-layer probe of the texture pool on c_effects shows where the memory goes:

  • Live images: 1 texture (126 MB) after every layer. The layers themselves don't keep their textures.
  • The shared pool's released list: grows by about 16 textures (about 1.1 GB) per layer, reaching 252 textures (17.7 GB) by layer 17.
  • Reserved device memory: 23.1 GB by layer 17, where the device runs out.

Those textures come from the effects path, Gpu::effects → run_chain. The CPU renderer calls it for each layer with GPU effects, and each call uses a fresh Enc. When that Enc drops, its textures go back to the shared pool. There they wait for every encoder that was open at the time of release to be submitted. The frame's own encoder stays open for the whole frame, so every effect intermediate stays allocated until the frame ends.

Change

First commit (13a49c9): Gpu::effects now runs on an Enc that borrows a pool owner, one Enc at a time, and gives it back once the call's work is submitted or discarded (Enc::lent). Giving it back trims the owner's kept textures to the existing 768 MB pool budget, so a 4K effect chain's intermediates are freed after each call instead of piling up until the frame ends. This is safe because the call's work is already queued before the textures are handed on, and its images never reach another encoder: the result is read back into a CPU buffer. 38 lines in crates/gpu/src/context.rs and 1 line in crates/gpu/src/lib.rs. (These line counts and the 768 MB per-owner budget describe the first commit only.)

Later commits (details in comment 6223542652): one shared free list with a 256 MiB budget; eviction only of sizes not requested in the last STALE_FRAMES = 2 output frames (otherwise the incoming texture is dropped); a dying-counter throttle so evicted bytes count against the budget until submitted work completes; frame_end on the multi-frame render paths (exports, remote frames, preview workers); a RenderEnd drop guard in export_async that empties the free list after an export; and Enc::record as the single encoder entry point, marking escaped leases so they go through the guarded release (covers the #1441 open-encoder case).

Early results (first commit 13a49c9 vs main, superseded)

These were measured before the pool rework and are kept for history. The early peak figure (more than 23 GB / OOM on main → 4.6 GB) is superseded by the current results in the Summary.

Vulkan, RTX 3090, 4K, 6 frames, 16 threads, H.264, warm shader cache.

Comp CPU only GPU, main (OOM → CPU) GPU, this PR GPU, this PR + #1511
c_effects 76.5 s 113.1 s 45.3 s 45.0 s
d_blurglow 47.9 s 75.2 s 57.3 s 58.0 s
  • Early, superseded: peak reserved GPU memory on c_effects dropped from more than 23 GB (OOM on main) to 4.6 GB, and the GPU rendered every frame.
  • Output vs the CPU render, 8-bit PNG frames 0, 3 and 5 at 4K: max difference 1/255 on about 0.01 % of pixels (680–760 px per frame, scattered across the whole frame), and no pixel differs by more than 2/255. That is float rounding at 8-bit quantisation, not a specific effect. At half resolution, where main fits in memory and renders on the GPU, this PR's frames are bit-identical to main's, so the change does not alter pixels. (The H.264 PSNR of 66–68 dB vs the CPU encode comes from 1-LSB input differences changing encoder decisions at 4K, not from visible error.)
  • Healthy 1080p comp, 30 frames, 3 runs each: 2.02–2.12 s on main vs 1.99–2.10 s with this PR, with identical output.

Notes

  • With the OOM gone, Export: render in parallel once the GPU stops for good #1511 and the frame-0 fail-fast change no longer matter for these comps. They still cover genuine device failures.
  • d_blurglow is still slower on the GPU than on the CPU (57 s vs 48 s in the early run). That is a separate per-frame cost to look into.

Testing

First commit: cargo fmt --check and clippy --no-deps -D warnings are clean. cargo test -p effectcraft-gpu gives 210 passed and 2 failed (tests_fx_pixel2::stylize, tests_fx_stylize::posterize_thresholds_strobe_vignette). Both also fail on unmodified main on the same software adapter.

After the rebase onto main 8f02f52 (head 73312c2, no code changes): cargo test -p effectcraft-gpu gives 218 passed and the same 2 failed on the software adapter; the 6 pool tests pass; fmt and clippy are clean.

@bflatastic

Copy link
Copy Markdown
Member

Thanks @MatejKozlovsky, the 23 GB → 4.6 GB drop at 4K is a big win. One concern before it lands: each idle pool owner can keep up to POOL_BUDGET (768 MB) outside the shared bytes accounting, and the number of idle owners grows to peak concurrency. With 16 frame threads that is about 12 GB held after a render and never trimmed. Could give_back trim against one global budget, or count idle owners' bytes in the shared total? Also note that if #1441 (results kept on the device) lands, it breaks the 'every chain ends in a readback' assumption, so please mention which should go first.

@MatejKozlovsky

Copy link
Copy Markdown
Contributor Author

Good catch, you're right, idle owners shouldn't sit outside the accounting. I'll make give_back trim against one global budget and count the idle bytes in the shared total, with a test for the 16-thread case. On order: this one first, then #1441. Combining them actually turned up a real bug (a handed-off texture can go back to its owner while the frame is still being encoded), so #1441 will carry that fix plus a test. Will push both soon.

MatejKozlovsky added a commit to MatejKozlovsky/effectcraft that referenced this pull request Oct 16, 2026
…ender

Review of storytold#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.
MatejKozlovsky added a commit to MatejKozlovsky/effectcraft that referenced this pull request Oct 16, 2026
Review of storytold#1523 (follow-up to 0a884fb):

- 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.
@MatejKozlovsky

Copy link
Copy Markdown
Contributor Author

Thanks for the review. I reworked the pool along those lines; the final state is f029766.

  • One shared free list, 256 MB. A finished lent call gives its textures to the shared free list, so idle owners no longer keep their own sets. POOL_BUDGET (now 256 MB) caps only the free list. Textures waiting on open encoders and those held by live Encs are outside it, and the docs now say so.
  • Evict only stale sizes. When the list is full, make_free evicts only sizes nobody asked for in the last STALE_FRAMES = 2 output frames. If nothing is stale, the incoming texture is dropped and the kept set keeps hitting. Plain LRU thrashed on blur/glow, which frees more than the budget in a repeating order.
  • Dying-counter throttle. Evicted bytes count against the budget until the work submitted so far is done, so we don't evict and reallocate far ahead of the GPU. It only throttles: wgpu frees on maintain, so it's not a hard bound.
  • frame_end on the multi-frame render paths. Exports (passes::comp_frame), remote frames and the preview workers all end their frames now. One-off renders (Session::render, single-frame export, thumbnails) don't call it, so they don't advance the clock; the Accelerator::frame_end doc says the same. Off export, the clock used to stand still, nothing went stale, and a full list dropped every newcomer.
  • Trim via a drop guard. export_async holds a RenderEnd guard, so render_end empties the pool's free list after the export, and also when it panics or its future is dropped.
  • Enc::record is the single entry point to the encoder outside context.rs, and it marks the images it names. A lease read by another Enc is marked escaped and always goes through the guarded Pool::release. That closes the GPU: keep an effect chain's result on the device for the compositor instead of uploading it again #1441 open-encoder case no matter which PR lands first.
  • Tests use private_gpu, so each test gets its own context and budget. The global override and its races are gone. New tests cover stale sizes being evicted, a repeating pattern that gets hits with zero evictions, an image read by an open encoder, and frames rendered through passes::comp_frame outside the export pipeline aging the pool.

Numbers: 4K, old = 13a49c9, new = f029766 (measured as a2a41e76; the code is the same, only comments and commit messages changed since). The measured builds are these commits plus a diagnostics-only patch (memory sampler, hit/miss counters). Each new figure is the median of 3 rounds; old on the 3090 had 2 rounds, so I give its range. Output md5 is identical between old and new in every run.

RTX 3090 24 GB / Ryzen 7 5800X:

  • c_effects: wall 46.6–52.4 s → 47.1 s; peak reserved 6080 → 6336–6592 MB
  • d_blurglow: wall 277.8–280.7 s → 245.1 s (about 12% faster); peak reserved 9204 → 8948–9204 MB

GTX 1070 8 GB / i7-7700HQ, driver 582.78:

  • c_effects: wall 84.7 → 84.8 s; peak reserved 6016 → 6272–6448 MB; pool hits/misses 7174/3800 → ~7191/3783
  • d_blurglow: wall 557.1 → 558.9 s; peak reserved 8149 MB on both (the card is full); hits/misses 378/184 on both

To be upfront: c_effects still peaks above the old code, by 256–512 MB on the 3090 and 256–432 MB on the 1070. d_blurglow is at or below the old peak. The speedup only shows on the 24 GB card. On the 8 GB card the change is neutral.

MatejKozlovsky and others added 6 commits October 16, 2026 22:27
… 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.
…ender

Review of storytold#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.
Review of storytold#1523 (follow-up to 0a884fb):

- 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.
…U 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.
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.
…ugh 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).
@MatejKozlovsky

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (8f02f52), no code changes: head is now 73312c2 (the same patches as f029766; the only conflicts were neighbouring tests in tests.rs next to #1549's new test). The numbers above were measured on f029766.

@FredApps FredApps left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed current head 73312c2 against main 8f02f52, including the shared free-list accounting, escaped-lease/open-encoder lifetime, frame-end call sites and export drop guard. I found no blocking correctness issue in the current patch.

Validation on this exact head: the six new pool regressions and four existing GPU output/reuse tests each passed individually (10 passed, 0 failed or ignored). All runs used NVIDIA GeForce RTX 5090 / Vulkan. For the six private-context tests, debugger backtraces confirmed execution reached the private budget setter after GPU initialization, rather than silently returning early. Changed-file formatting, scoped GPU/render/export library Clippy with warnings denied, and source-integrity checks passed.

The performance scope matters: 256 MiB caps the shared free list, not total live or driver-reserved GPU memory. I did not run the 4K performance benchmarks. The later author measurements in comment 6223542652 supersede the opening 23 GB-to-4.6 GB headline: they show mixed peak-memory results and a blur/glow speedup on the 24 GB card, not a universal memory reduction. Please keep that distinction clear in the PR summary/release notes.

This review is for this patch alone, without integrating the unmerged #1441/#1550/#1706 work.

@MatejKozlovsky

Copy link
Copy Markdown
Contributor Author

Thanks @FredApps for the thorough review and for testing on the RTX 5090. I've updated the PR summary to make the distinction clear: the 256 MiB budget caps only the shared free list of unused textures, not total live or driver-reserved GPU memory. The early 23 GB → 4.6 GB figure is now labelled as superseded by the mixed results in comment 6223542652.

MatejKozlovsky added a commit to MatejKozlovsky/effectcraft that referenced this pull request Oct 18, 2026
…er reuses it

An effect chain's result handed to the upload cache (adopt_upload) is read by the
frame's encoder, which may not be submitted yet. When the cache entry went (its layer
buffer died mid-frame, e.g. a 4K export whose layer cache can't keep the buffers), the
texture went straight back to the owner Enc's private list, skipping the open-encoder
guard, and with a lent owner (storytold#1523) the next same-size chain overwrote it before the
frame read it. The lease now remembers it escaped and goes through Pool::release.

Tests: a pool-level check that a handed-off texture is not reacquired while another
encoder is open, and a frame of five same-size blur + glow layers whose buffers die
mid-frame (asserting results really were released mid-frame), vs the CPU and vs the
hand-off off. On storytold#1441 alone only the pool test fails without the fix; the frame
test covers the interaction with storytold#1523's lent owners. EFFECTCRAFT_NO_HANDOFF is now an
AtomicBool so tests can switch it (restored by a guard, also on panic).
@MatejKozlovsky

Copy link
Copy Markdown
Contributor Author

@bflatastic FredApps approved this on 17 Oct, your idle-owner point is handled by the 16 Oct rework (one shared 256 MB free list), and it still merges cleanly with main. Could it go in, or into the next community batch?

The order is still this first, then #1441. #1441 is based on main, not on this branch, but its 4K gain only shows on top of this one. Once this lands, I'll push the one-line Enc::lent change to #1441 and sort out the small overlap in context.rs / tests.rs. #1550 depends on this one too.

This branch has not been deployed

No deployments
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.

3 participants