Skip to content

#879: cap GPU frames in flight during export - #1184

Merged
h-a-n-a-s-h-i merged 3 commits into
storytold:mainfrom
MatejKozlovsky:gpu-frame-inflight-cap
Oct 13, 2026
Merged

h-a-n-a-s-h-i merged 3 commits into
storytold:mainfrom
MatejKozlovsky:gpu-frame-inflight-cap

Conversation

@MatejKozlovsky

Copy link
Copy Markdown
Contributor

Fixes #879

Problem

During export, Cx::frames (crates/export/src/pipeline.rs) rendered every frame of a batch at once (ks.into_par_iter()). With a GPU accelerator, each frame in flight holds its own working textures. On the #879 project (142 shape and text layers, 1080p), a 30-frame --gpu export used about 20 GB above idle on an RTX 3090 (23.9 GB total). On an 8 GB card it filled the card, and main quietly rendered every frame on the CPU: its PNGs are byte-identical to a CPU render. On shared-memory GPUs (the Intel and AMD reports in #879) the same burst can take the process down instead.

Change

  • Only the GPU render is capped, only for GPU-accelerated projects. The cap applies only when an accelerator is attached and the project's GPU acceleration is on (the same condition Export: stream frames through a shared CPU/GPU work queue, convert to YUV 4:2:0 on the GPU, and tune to the hardware #898 uses). Software Only projects and accelerator-less exports keep the one parallel batch they had.
  • One frame by default, on every vendor. This is what Export: stream frames through a shared CPU/GPU work queue, convert to YUV 4:2:0 on the GPU, and tune to the hardware #898's single GPU worker does for image sequences: its async readback, and so more than one frame in flight, only applies with GPU YUV conversion. On an 8 GB card, three frames at once still filled the card; one stayed on the GPU at about 6.0 GB.
    • The default isn't sized from free VRAM, because the GPU backend reports no free or total device memory portably.
    • It is decided once per batch.
  • Encoding stays parallel. Frames go in windows of max(cap, threads). Each window renders in chunks of the cap, then encodes in parallel, so at most one window of images is held in RAM.
  • Override: EFFECTCRAFT_GPU_INFLIGHT=N. An invalid value (0, negative, not a number, too large) logs a warning and uses the default.
  • Safety: the chunks hold no lock or slot. A failing or panicking frame can't leak one, and nested parallelism inside a frame or a single Rayon thread can't deadlock.
  • Scope: no new dependencies, and no unwrap, expect, panic or unsafe outside tests.

Interaction with #898

#898's export scheduler already renders through a single GPU worker on the GPUs it is tuned for (NVIDIA discrete, Apple silicon). On top of #898, this change only affects the Plan::Parallel(Gpu) fallthrough: other vendors and the stock path. That is the path the #879 reporters (Intel, AMD) are on. Both PRs touch Cx::frames. I'm happy to rebase whichever lands second.

Tests

New unit tests in pipeline::tests:

  • output order is preserved
  • the cap is respected (most renders at once, counted with atomics)
  • encoding still runs in parallel under cap 1
  • a cap above the frame count, and an empty batch
  • cap 1 on a single thread, with nested parallelism, doesn't deadlock
  • an Err frame doesn't hold up the rest, and a panicking frame propagates once with the next batch running
  • a Software Only project with an accelerator isn't capped; GPU acceleration on is capped; no accelerator isn't capped
  • parsing of the EFFECTCRAFT_GPU_INFLIGHT override, including overflow

Results:

  • cargo fmt --all -- --check, cargo clippy -p effectcraft-export --all-targets -- -D warnings and cargo test -p effectcraft-export all pass (81 tests).
  • cargo xtask ci on Windows: 103 passed, 1 failed. The failure is effectcraft-cli render_out_is_relative_to_the_working_directory, a \\?\ verbatim-path mismatch from canonicalisation on Windows (left: C:\...\out\d.gif, right: \\?\C:\...\out\d.gif). It fails the same way on main 9efe753, so it's pre-existing and unrelated to this change.
  • The branch merges cleanly onto current main (0.9.2, 470ea7a); pipeline.rs is unchanged upstream.

Measurements

Final head, test PC with an RTX 3090 (24 GB, idle about 1.25 GB). Each value is the median of 3 runs of a 30-frame export, PNG.

Case main: wall / peak this PR: wall / peak
#879 project, 1080p, --gpu (142 shape and text layers) 10.35 s / 23.6 GB (card nearly full) 8.91 s / 7.9 GB
light project, 720p, 2 layers, --gpu 1.79 s / 3.4 GB 1.78 s / 2.6 GB
#879 project, Software Only (CPU) 6.86 s 7.79 s (same code path, no cap; run-to-run noise)

Output parity (PNG md5, all 30 frames):

  • Software Only: this PR is byte-identical to main.
  • Light project: byte-identical to main.
  • Export with --gpu fills VRAM (~14.5 of 16 GB, 1080p, 30 frames) and aborts with a failed allocation on a heavy comp (RX 7600 XT) #879 project: this PR is byte-identical to main run with RAYON_NUM_THREADS=1 (main's GPU output) and stable across runs. With default threads, main's PNGs are byte-identical to its Software Only render. So even on 24 GB, main quietly falls back to the CPU for this project once the card fills, while this PR renders on the GPU.
  • ProRes (proxy, 30 frames): both decode to 30 frames. They differ only as the GPU render differs from the CPU one: PSNR 84 dB on average (min 81.6 dB).

Earlier runs at one frame in flight, before the review fixes, which don't change the render cap. 30-frame 1080p --gpu export of the #879 project:

Test machine Build Wall Peak (above idle) Rendered on
test laptop with a GTX 1070 (8 GB) main 11.8–11.9 s card full CPU (quiet fallback)
test laptop with a GTX 1070 2 / 3 in flight 11.2–20.3 s +6.7 GB to card full GPU
test laptop with a GTX 1070 this PR (1 in flight) 15.3 s +5.6 GB GPU
test laptop with a GTX 1070 #898 10.4–19.7 s +4.5–4.8 GB GPU
test PC with an RTX 3090 #898 6.4–6.7 s +5.9–6.2 GB GPU

With a GPU accelerator every frame of a batch rendered in parallel, each
holding its own working textures, so a 30-frame 1080p batch could take
the whole card (20+ GB on an RTX 3090) and run shared-memory GPUs out of
memory. Cx::frames now renders GPU frames in consecutive chunks of 3
(EFFECTCRAFT_GPU_INFLIGHT overrides it), the same cap as the export
scheduler in storytold#898; output order and the CPU path are unchanged.

Fixes storytold#879
Three frames at once still filled an 8 GB card on the storytold#879 project at
1080p (7.9 GB, the same as no cap); one frame peaked at 6.3 GB and two at
7.0 to 7.8 GB. One matches how the export scheduler in storytold#898 renders image
sequences (one GPU worker, synchronous readback). EFFECTCRAFT_GPU_INFLIGHT
still raises it.
…#879 review)

- The cap applied whenever an accelerator was attached, so Software Only
  projects (which render on the CPU) rendered one frame at a time. It
  now needs the project's GPU acceleration too, as storytold#898 gates it.
- Only the render is capped: each window of max(cap, threads) frames
  renders in chunks of the cap, then encodes in parallel, holding at
  most one window of images.
- An invalid EFFECTCRAFT_GPU_INFLIGHT (0, negative, not a number, too
  large) logs a warning before using the default.
- The default stays one frame: the GPU backend reports no free or total
  device memory portably, so it is not sized from VRAM.
@echelon

echelon commented Oct 13, 2026

Copy link
Copy Markdown
Member

Merged. Thank you for the contribution, @MatejKozlovsky! It landed on main together with a batch of other community PRs, after a full workspace build and test run. If you are not on our Discord yet, please join us: https://discord.gg/artcraft

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.

Export with --gpu fills VRAM (~14.5 of 16 GB, 1080p, 30 frames) and aborts with a failed allocation on a heavy comp (RX 7600 XT)

3 participants