Skip to content

GPU: keep an effect chain's result on the device for the compositor instead of uploading it again - #1441

Open
MatejKozlovsky wants to merge 3 commits into
storytold:mainfrom
MatejKozlovsky:gpu-handoff
Open

MatejKozlovsky wants to merge 3 commits into
storytold:mainfrom
MatejKozlovsky:gpu-handoff

Conversation

@MatejKozlovsky

@MatejKozlovsky MatejKozlovsky commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Base: main. Three commits: the hand-off itself, a fix so a handed-off result waits for open encoders before it is reused, and the chain hold returned from Accelerator::effects_held instead of a thread-local. It no longer stacks on #1415 (closed in favour of #1523). It works on main alone, but its 4K gain shows only on top of #1523 (see Results and "Relationship to #1523").

Problem

With GPU acceleration, a layer whose effects run on the GPU (Gpu::effects → run_chain) makes three trips:

  1. Its CPU buffer is uploaded (RGBA f32).
  2. The chain runs, and the result is read back to the CPU (it becomes the layer's buffer and goes into the layer cache).
  3. The GPU compositor uploads that same buffer again to composite it.

Blur and glow make the buffer bigger by their reach, so the result is larger than the input. Step 3 uploads that largest buffer again, though the GPU already had it.

Change

  • Chain hold. run_chain_held returns the chain's result and, when the hand-off is on, the device image that was read back. run_chain is now a wrapper that drops the image. The new Accelerator::effects_held (default: effects with no hold) wraps that image in an opaque ChainHold that records the address and length of the pixels it was read back into. The caller owns the hold, and nothing is kept in the accelerator or in a thread-local. Results served through deferred readback (the browser worker path) carry no hold.
  • Renderer. The CPU effect target (CpuFx) calls effects_held and keeps the hold only while its buffer is still that chain's result: any CPU effect after the chain drops it. content_buf wraps the stack's result in the layer Arc and, only if ChainHold::is_for confirms the Arc holds those very pixels, passes it to the new Accelerator::adopt (default: nothing). Other callers of the effect stack (layer_input, apply_effects_timed) drop the hold.
  • Upload cache. Gpu::adopt files the image in the existing upload cache under that Arc (adopt_upload, size-checked). The cache identifies buffers by Arc identity, checked through a Weak, and adopted entries count against its budget and are swept like uploads. The compositor's upload_buf then finds the texture and skips step 3. The readback (step 2) still happens.
  • Reuse safety (open-encoder fix). An adopted image's pool lease is marked escaped. When it is dropped it goes through Pool::release, which waits for every open encoder, instead of straight back to the Enc that made it. Without this, the frame's encoder could still have an unsubmitted read of the texture while the owner's next chain overwrites it.
  • Switch. Gpu::set_handoff (on by default). For A/B comparisons, the CLI turns it off for --gpu sessions and bench --gpu when EFFECTCRAFT_NO_HANDOFF is set. Other hosts (the app) leave it on. With it off, no image is held.
  • Output. The adopted texture is exactly the image that was read back (after the per-step quantisation when a bit depth is set), so composited pixels don't change.
  • Tests. gpu: handed_off_texture_waits_for_open_encoders, and handoff_survives_layer_buffers_dying_mid_frame (frame matches the CPU and the hand-off-off render while handed-off results die mid-frame). render: chain_hold_matches_only_its_own_pixels.

Results

RTX 3090 24 GB (Vulkan, driver 595.97), Ryzen 7 5800X, Windows 10. Sample d (30 blur + glow layers), 30 frames, H.264, --gpu, EFFECTCRAFT_GPU_INFLIGHT=1 (the default), 3 runs per cell. "Off" is the same exe with EFFECTCRAFT_NO_HANDOFF=1. Wall time is mean (min–max). Peak VRAM is device-wide (nvidia-smi, includes about 0.9 GiB idle). Full measurements and caveats: #1441 (comment)

On main (8f02f52), one session:

Build 4K wall 4K peak 1080p wall 1080p peak
main 240.1 s (238.2–242.2) 23.3 GiB 35.5 s (35.4–35.6) 10.5–10.7 GiB
this PR, on 239.0 s (237.2–241.2) 23.4 GiB 31.8 s (31.7–31.8) 10.3–10.4 GiB
this PR, off 240.5 s (236.0–243.7) 23.3 GiB 35.3 s (35.3–35.4) 10.5–10.7 GiB

On #1523 (73312c2), with the one-line Enc::lent change below. The 4K and 1080p columns are separate sessions (the 1080p one ran about 4.5 % slower overall), so compare within a column:

Build 4K wall 4K peak 1080p wall 1080p peak
#1523 200.3 s (194.9–203.1) 10.3–10.5 GiB 37.9 s (37.4–38.6) 4.0 GiB
this PR on #1523, on 131.4 s (129.0–135.4) 11.5–11.7 GiB 33.9 s (33.2–34.4) 5.2 GiB
this PR on #1523, off 200.6 s (195.7–203.5) 10.3–10.6 GiB 37.1 s (36.8–37.7) 4.0 GiB

Relationship to #1523

Testing

At d55dd74:

  • cargo fmt --all -- --check: clean.
  • cargo clippy --no-deps -p effectcraft-gpu -p effectcraft-render -p effectcraft-cli --all-targets -- -D warnings: clean.
  • cargo test -p effectcraft-gpu --lib (llvmpipe): 214 passed, 2 failed. The two failures (tests_fx_pixel2::stylize, tests_fx_stylize::posterize_thresholds_strobe_vignette) are known llvmpipe failures that also fail on main.
  • cargo test -p effectcraft-render: 221 passed, 1 ignored (plus motion_blur_probe 1 passed).
  • On the RTX 3090, with the stack on GPU: free effect working textures after each call instead of at frame end #1523: focused pool/hand-off/lent tests passed 9/9.

Not in this PR

  • The readback itself still happens, because the layer cache holds CPU buffers. Keeping chain results on the device until something on the CPU needs them would remove it too. That is a bigger change.
  • Lower-precision (f16) transfers would halve the bytes but change the output.
  • Adjustment layers already keep their stacks on the device (GpuFx); unchanged.

Caveats

  • One GPU (RTX 3090), one CPU (Ryzen 7 5800X), one scene (sample d), Vulkan only, 3 runs per cell, no formal statistics. Not checked on an 8 GB card, AMD or Intel, or DX12.
  • Peak VRAM is device-wide nvidia-smi, polled every 250 ms or more, so short spikes can be missed.
  • The 4K output of the GPU: free effect working textures after each call instead of at frame end #1523 rows was compared with main's only by H.264 PSNR (luma 56–74 dB, decoded max difference 5/255), not against lossless frames.
  • The first launch of each new exe added about 6 s of wall time. This counts against the PR's 4K "on" row (131.4 s mean; its render-time mean is 127.9 s).

MatejKozlovsky and others added 3 commits October 17, 2026 00:34
…instead of being uploaded again after its readback
…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).
… of a thread-local; the CLI reads EFFECTCRAFT_NO_HANDOFF

Review points on storytold#1441:
- No thread-local LAST_CHAIN: Accelerator::effects_held returns the device copy with
  the result, the renderer's CpuFx owns it for the stack and drops it when a CPU effect
  runs, so nothing is pinned past the call that made it (paths that use effects() keep
  nothing).
- ChainHold is an opaque struct instead of Box<dyn Any>: it records the pixels it was made
  for and the renderer checks ChainHold::is_for (address and length) before adopt.
- The library no longer reads EFFECTCRAFT_NO_HANDOFF: Gpu::set_handoff, set by the CLI.
@MatejKozlovsky

Copy link
Copy Markdown
Contributor Author

Follow-up measurements with this PR at d55dd74, now pushed: rebased onto main 8f02f52, plus the open-encoder fix and effects_held instead of the thread-local. The numbers in the description were measured on top of #1415, which was closed in favour of #1523, so they no longer describe this PR on main. An earlier measurement of an older combined build with #1523 (134 s with the hand-off vs 270 s without) is superseded too. The on time reproduces (131 s below), but the off baseline is now about 200 s, so the gain is about 1.5x, not 2x.

Setup: RTX 3090 24 GB (Vulkan, driver 595.97) / Ryzen 7 5800X, Windows 10, sample d (30 blur + glow layers), 30 frames, H.264, --gpu, EFFECTCRAFT_GPU_INFLIGHT=1 (the default), otherwise idle, 3 runs per row. "Off" is EFFECTCRAFT_NO_HANDOFF=1. Wall time is mean (min–max). Peak VRAM is device-wide (nvidia-smi, includes about 0.9 GiB idle).

On main (8f02f52; main has since moved, with no GPU or renderer changes), one session:

Build 4K wall 4K peak 1080p wall 1080p peak
main 240.1 s (238.2–242.2) 23.3 GiB 35.5 s (35.4–35.6) 10.5–10.7 GiB
this PR, on 239.0 s (237.2–241.2) 23.4 GiB 31.8 s (31.7–31.8) 10.3–10.4 GiB
this PR, off 240.5 s (236.0–243.7) 23.3 GiB 35.3 s (35.3–35.4) 10.5–10.7 GiB

On #1523 (73312c2). The 4K and 1080p columns come from two different sessions, so compare within a column:

Build 4K wall 4K peak 1080p wall 1080p peak
#1523 200.3 s (194.9–203.1) 10.3–10.5 GiB 37.9 s (37.4–38.6) 4.0 GiB
this PR on #1523, on 131.4 s (129.0–135.4) 11.5–11.7 GiB 33.9 s (33.2–34.4) 5.2 GiB
this PR on #1523, off 200.6 s (195.7–203.5) 10.3–10.6 GiB 37.1 s (36.8–37.7) 4.0 GiB

Limits: one GPU, one scene, Vulkan only. Not checked on an 8 GB card, on AMD or Intel, or on DX12.

@00xJS

00xJS commented Oct 18, 2026

Copy link
Copy Markdown

A data point from AMD, since the caveats say it hasn't been checked there: RX 6900 XT 16 GB, Windows 11, Adrenalin 32.0.21045.5002, Vulkan, head d55dd742, release effectcraft-cli. "Off" is the same exe with EFFECTCRAFT_NO_HANDOFF=1. 3 interleaved runs per row. Peak VRAM is the Windows "GPU Adapter Memory → Dedicated Usage" counter, sampled every ~0.3 s, minus 1.9 GiB idle.

I don't have sample d, so this is a stand-in: 30 animated 300 px solids (scaled ×2 at 4K), each with Gaussian Blur (15–35) + Glow (radius 40), 30 frames, H.264. It's much lighter than yours, so read it as a check for correctness and memory, not as a reproduction of your timings.

1080p wall 1080p peak 4K wall 4K peak
CPU (no --gpu) 5.1 s — 18.9 s —
hand-off on 7.4 s (6.4–8.9) 0.89 GiB 14.2 s (14.1–14.3) 2.39–2.41 GiB
hand-off off 7.5 s (6.6–9.1) 1.14–1.16 GiB 14.7 s (14.3–15.0) 2.90–2.96 GiB

Not checked: DX12 (main needs #1571 there), and an 8 GB card.


Generated with Claude Code

@MatejKozlovsky

Copy link
Copy Markdown
Contributor Author

Thanks a lot, this is really useful. Seeing byte-identical output and lower peak VRAM with the hand-off on is good news for the AMD path.

Here's sample d. Save the two scripts below as common.jsx and d_blurglow.jsx, then build it with:

effectcraft-cli script <(cat common.jsx d_blurglow.jsx) --empty --save-as d_blurglow.ecproj

(On Windows without bash, concatenate the two files into one .jsx and pass that.) It makes comp Bench, 3840×2160, 30 fps, 10 s: 30 shape/text layers, each with Gaussian Blur (80–230) and Glow (radius 200–500, intensity 2), and every third one also gets Fast Box Blur 100.

common.jsx:

var FPS=30, W=3840, H=2160, DUR=10;
function rnd(i){ var x=Math.sin(i*12.9898)*43758.5453; return x-Math.floor(x); }
function comp(name){ return app.project.items.addComp(name, W, H, 1, DUR, FPS); }
function T(l,n){ return l.property("ADBE Transform Group").property(n); }
function anim(l,i){
  T(l,"ADBE Position").setValueAtTime(0,[rnd(i)*W, rnd(i+1)*H]);
  T(l,"ADBE Position").setValueAtTime(DUR,[rnd(i+2)*W, rnd(i+3)*H]);
  T(l,"ADBE Rotate Z").setValueAtTime(0,0); T(l,"ADBE Rotate Z").setValueAtTime(DUR,360*rnd(i+4));
  T(l,"ADBE Scale").setValueAtTime(0,[60,60]); T(l,"ADBE Scale").setValueAtTime(DUR,[60+80*rnd(i+5),60+80*rnd(i+5)]);
}
function shape(c,i){
  var sh=c.layers.addShape();
  var g=sh.property("ADBE Root Vectors Group").addProperty("ADBE Vector Group");
  var r=g.property("ADBE Vectors Group").addProperty(i%2?"ADBE Vector Shape - Ellipse":"ADBE Vector Shape - Rect");
  r.property("Size").setValue([200+300*rnd(i),200+300*rnd(i+9)]);
  var f=g.property("Contents").addProperty("ADBE Vector Graphic - Fill");
  f.property("Color").setValue([rnd(i),rnd(i+7),rnd(i+8)]);
  return sh;
}
function fx(l,name,props){ var e=l.property("ADBE Effect Parade").addProperty(name); for(var k in props){ e.property(k).setValue(props[k]); } return e; }
function text(c,i,s){ var t=c.layers.addText(s); var sp=t.property("ADBE Text Properties").property("ADBE Text Document"); var d=sp.value; d.fontSize=120+80*rnd(i); d.fillColor=[1,rnd(i),rnd(i+3)]; sp.setValue(d); return t; }

d_blurglow.jsx:

var c=comp("Bench");
c.layers.addSolid([0,0,0],"BG",W,H,1);
for(var i=0;i<30;i++){ var l=(i%3==0)?text(c,i,"GLOW"):shape(c,i); anim(l,i);
  fx(l,"Gaussian Blur",{"Blurriness":80+150*rnd(i)}); fx(l,"Glow",{"Glow Radius":200+300*rnd(i),"Glow Intensity":2});
  if(i%3==1) fx(l,"Fast Box Blur",{"Blur Radius":100,"Repeat Edge Pixels":true}); }

My exact render command was the following, with EFFECTCRAFT_GPU_INFLIGHT=1, and the same command plus EFFECTCRAFT_NO_HANDOFF=1 for "off":

effectcraft-cli render --project d_blurglow.ecproj --comp Bench --out v.mp4 --format h264 --start 0 --end 1 --resolution full|half --audio off --gpu

One heads-up: on main without #1523, the 4K run filled my 24 GB card and the effects most likely fell back to the CPU, so on 16 GB I'd expect the same. The 1080p (--resolution half) numbers are probably the informative ones on main alone. On the 3090 that was about 10 % faster with the hand-off on.

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.

2 participants