Repository navigation
fix(export): prevent OOM crashes via memory-budget batching and encoder optimizations - #1212
thanhcong2k7 wants to merge 1 commit into
Conversation
ed1104b to
215da37
Compare
|
Thank you for working on export memory, @thanhcong2k7! A few things before this can land:
If you're not on our Discord yet, please join us: https://discord.gg/artcraft |
215da37 to
2a7542b
Compare
|
Thanks for the review, @echelon! I've addressed all the feedback and force-pushed the update:
All unit tests pass ( Also saw @MatejKozlovsky's note in #898 regarding the budgeting overlap ( |
…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>
|
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:
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. |
|
Thank you @thanhcong2k7! Closing this as it stands. |
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 aVecthroughout the batch lifecycle, and per-row heap allocations during YUV conversion caused severe allocator churn.Changes
crates/render-export/src/lib.rs:batch_size()withdynamic_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.sequenceandgif_exportto pass dimensions todynamic_batch_size(w, h).crates/render-export/src/encode.rs:dynamic_batch_size(w, h)..drain(..)during the encode loop, immediately dropping each frame's memory after consumption rather than holding the entire batch.rgba_to_yuv420by removing per-row heap allocations (vec![(0f32, 0f32, 0f32); cw]) inside parallel chunks, eliminating allocator contention and fragmentation across Rayon threads.Testing
cargo testto verify all existing and newly added regression tests pass (dynamic_batch_size_scales_with_resolution,yuv_conversion_arbitrary_dimensions).