Skip to content

fix(export): prevent OOM crashes via memory-budget batching and encoder optimizations - #1212

Closed
thanhcong2k7 wants to merge 1 commit into
storytold:mainfrom
thanhcong2k7:fix/memory-optimization
Closed

thanhcong2k7 wants to merge 1 commit into
storytold:mainfrom
thanhcong2k7:fix/memory-optimization

Conversation

@thanhcong2k7

@thanhcong2k7 thanhcong2k7 commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes an issue where video and sequence exports caused excessive memory consumption, leading to system out-of-memory (OOM) crashes on 16GB RAM systems within seconds of starting a render.

Previously, batch_size() determined frame concurrency solely based on logical CPU threads (rayon::current_num_threads().clamp(2, 16)). On multi-threaded CPUs (e.g. 6-core / 12-thread), this spawned up to 12 concurrent frame renders regardless of composition complexity or resolution. Because each frame evaluates 32-bit float (f32) RGBA rasters along with intermediate effect/layer buffers, concurrent allocations easily exceeded physical memory limits. Additionally, batch frames were retained in a Vec throughout the batch lifecycle, and per-row heap allocations during YUV conversion caused severe allocator churn.

Changes

  • crates/render-export/src/lib.rs:
    • Replaced thread-only batch_size() with dynamic_batch_size(w, h). Concurrency is now dynamically calculated based on a safe memory budget (~2.5 GB) and physical core limits rather than raw logical thread counts.
    • Updated sequence and gif_export to pass dimensions to dynamic_batch_size(w, h).
    • Added unit tests verifying batch size scaling across resolutions (tiny, 720p, 1080p, 4K) and edge cases.
  • crates/render-export/src/encode.rs:
    • Updated movie export to use dynamic_batch_size(w, h).
    • Eagerly freed frame buffers using .drain(..) during the encode loop, immediately dropping each frame's memory after consumption rather than holding the entire batch.
    • Optimized rgba_to_yuv420 by removing per-row heap allocations (vec![(0f32, 0f32, 0f32); cw]) inside parallel chunks, eliminating allocator contention and fragmentation across Rayon threads.
    • Added a unit test validating YUV conversion accuracy across multi-row dimensions.

Testing

  • Unit Tests: Ran cargo test to verify all existing and newly added regression tests pass (dynamic_batch_size_scales_with_resolution, yuv_conversion_arbitrary_dimensions).
  • Memory & Export Verification: Tested a 1080p H.264 export on an Intel Core i7-10750H (6 cores / 12 threads) with 16GB RAM. Peak memory usage remained bounded between ~2.2GB - 3.5GB without disk thrashing or OOM, compared to crashing within a few seconds prior to this patch.

@thanhcong2k7
thanhcong2k7 force-pushed the fix/memory-optimization branch from ed1104b to 215da37 Compare October 13, 2026 08:40
@echelon

echelon commented Oct 13, 2026

Copy link
Copy Markdown
Member

Thank you for working on export memory, @thanhcong2k7! A few things before this can land:

  1. It removes Uncompressed QuickTime export. The OutputFormat::Uncompressed arm and its docs row are deleted, which looks like the branch was based on an older main. Please rebase on current main and keep that format.
  2. Never-crash rule: the new rgba_to_yuv420 loop indexes src_row[x * 4], yr[...] and the slice rgba[(cy * 2 + dy) * w * 4..] directly. Non-test code here uses get() / checked arithmetic (see AGENTS.md, "Never crash"). The old version's accumulator layout was fine. Is the rewrite needed for the memory fix at all?
  3. Batch size: halving the thread count and assuming a fixed 2.5 GB budget slows exports on large machines, and it doesn't account for #879: cap GPU frames in flight during export #1184 (just landed, caps GPU frames in flight) or the memory budget work in 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. Please base the budget on the system's available memory (there is a memory probe in sysinfo), and measure before and after.
  4. Please drop the commented-out batch_size and add the trailing newlines.

If you're not on our Discord yet, please join us: https://discord.gg/artcraft

@thanhcong2k7
thanhcong2k7 force-pushed the fix/memory-optimization branch from 215da37 to 2a7542b Compare October 13, 2026 13:50
@thanhcong2k7

Copy link
Copy Markdown
Contributor Author

Thanks for the review, @echelon! I've addressed all the feedback and force-pushed the update:

  1. Rebased on main & preserved Uncompressed: Rebased onto the latest main, keeping OutputFormat::Uncompressed in both the documentation table and export_with match arm.
  2. Never-crash rule: Reverted rgba_to_yuv420 back to the original checked accumulator layout to avoid direct indexing. Kept the eager .drain(..) in encode::movie so rendered frame buffers are dropped immediately after being consumed.
  3. Dynamic RAM budget via sysinfo: Replaced the fixed 2.5 GB cap and halved thread limit with dynamic budgeting using effectcraft_engine::sysinfo::memory(). The batch size now scales based on actual available system memory and resolution without artificially bottlenecking high-core/high-RAM machines.
  4. Code cleanup: Removed the commented-out batch_size and ensured proper trailing newlines.

All unit tests pass (cargo test -p effectcraft-export --lib, 20/20). On my 16 GB test machine (6c/12t), a 1080p H.264 export now stays bounded around ~2.5–3.5 GB peak RAM without system freezes or OOM.

Also saw @MatejKozlovsky's note in #898 regarding the budgeting overlap (cpu-batch / ram-budget-mb) - fully agreed that whichever lands second can adapt to the other's options rather than duplicating budgets.

iannitram added a commit to iannitram/effectcraft that referenced this pull request Oct 15, 2026
…thread

An export rendered clamp(threads, 2, 16) frames at once whatever they cost.
Each frame in flight holds its own layer and effect buffers: about 1.9 GB per
1080p frame of a layer-heavy After Effects intro, so 16 at once peaked at
37.7 GB on a 46 GiB machine and came within 1.4 GB of the out-of-memory
killer (a full export peaked at 38.5 GB on 2026-10-13).

in_flight::Batch keeps batch_size() as the ceiling, starts at what the free
memory surely holds (a 2 GB first guess per frame), measures each batch's real
cost per frame (the process's peak resident memory over the batch, VmHWM,
above where it began; reset through /proc/self/clear_refs), and sizes every
later batch to MemAvailable minus a reserve (the larger of 4 GiB and a tenth
of RAM): down when memory runs short, back up to the ceiling when it frees.
Linux only; elsewhere the ceiling stands. One `decision: export:` info line
when a batch is below the ceiling. Each export loop changes by two lines, so
this composes with upstream PR storytold#1212 (whose batch_size would become the
ceiling).

Measured on the Z13 host (Ryzen AI Max+ 395, 46 GiB), intro16 10-13 s
(90 frames, 1080p H.264), 32 threads, under memwatch:
  before (16 at once)          181 s, peak 37.7 GB, 4.2 GB left
  after, idle machine          193 s, peak 35.4 GB, 7.0 GB left
  after, 16 GB taken by another process
                               227 s, peak 23.3 GB, 3.3 GB left
  (16 threads before, no other load: stopped by memwatch at 1.5 GB left)
Every output byte-identical (ffmpeg framemd5).

Tests: in_flight unit tests (what fits, the reserve, between 1 and the
ceiling; following a measured cost down and back up; the first batch from
free memory; this machine's readings), and roundtrip
frames_rendered_at_once_do_not_change_the_output (pools of 2 and 8 threads:
MP4 and PNG sequence byte for byte, WebM decoded frames; WebM writes one
Cluster per batch, as it already did per thread count).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@iannitram

Copy link
Copy Markdown
Contributor

Thanks for this: export memory is a real problem on big comps, and we have been measuring the same thing on a fork. A few observations, each checked against this PR's code:

  1. Exported files would depend on free memory. Audio is mixed and written once per render batch (write_audio_until at each batch end in encode.rs; WebM also closes a Cluster per batch in webm.rs / webm_av1.rs). Today the batch follows the thread count, so the same export already differs between machines with different core counts. With dynamic_batch_size the batch follows free RAM, so the same project exported twice on the same machine can give different files (audio sample placement and container layout; the video frames stay identical). We fixed this on our side by mixing and writing audio, and closing WebM Clusters, on a fixed grid of output frames independent of the batch, with a test that exports one comp with a sound layer under 2, 4 and 8 threads and compares the MP4, MOV and WebM files byte for byte. Happy to send that as its own small PR, since it stands without this one.
  2. Memory per frame is underestimated for layer-heavy comps. The estimate is about 530 MB per 1080p frame; on a layer-heavy 1080p project we measured about 1.9 GB per frame in flight, so the budget can still over-commit.
  3. Windows memory reading. Free memory is read by starting PowerShell (crates/engine/src/sysinfo.rs, about 1 s per call), and the figure it returns (FreePhysicalMemory, which excludes the standby cache Windows counts as available) under-reports what is available, often by several GB. GlobalMemoryStatusEx (ullAvailPhys) gives it in microseconds without a child process.
  4. A described change is missing. The description mentions a YUV conversion speed-up, but rgba_to_yuv420 only gains a blank line (acc = vec![...] is still allocated per chunk), and the yuv_conversion_arbitrary_dimensions test it mentions is not in the diff.
  5. Docs. The formats table at the top of crates/export/src/lib.rs loses its Uncompressed row, although Uncompressed export still works, so that looks unintended.
  6. Non-deterministic test. dynamic_batch_size_scales_with_resolution reads the machine's live free memory on every call, so its result depends on the machine and what else is running; with ample RAM every resolution clamps to the thread count and the scaling is never exercised. Passing the available-memory figure in as a parameter would make it deterministic.

An approach that has worked for us: start with a small batch, measure what each frame actually cost (process peak against available memory), and size the next batch from that, re-measuring as it goes. Glad to share numbers if useful.

@bflatastic

Copy link
Copy Markdown
Member

Thank you @thanhcong2k7! Closing this as it stands. crates/export (layer L3) now depends on effectcraft-engine (L4), which cargo xtask layers rejects. The diff also deletes the Uncompressed row from the format table, and doesn't contain several changes the description lists (the YUV row allocations, the new test). The memory work for 4K export is moving in #1523 / #1550; a focused PR on top of those, without the layer dependency, would be very welcome.

@bflatastic bflatastic closed this Oct 16, 2026
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.

4 participants