From 46040928c8892f6d4fc1743869c090d229ab6dd9 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sat, 17 Oct 2026 21:56:54 -0400 Subject: [PATCH] #1615: cap CPU frames in flight during export (EFFECTCRAFT_CPU_INFLIGHT) CPU renders kept one frame per thread in flight with no cap, so heavy comps could be OOM-killed. EFFECTCRAFT_CPU_INFLIGHT=N renders at most N frames at once through the existing capped path; unset keeps today's behaviour. With a GPU cap too, the smaller cap holds for the whole batch. Co-Authored-By: Claude Opus 5.5 --- crates/export/src/pipeline.rs | 68 ++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 8dcee03e4..bd80a2a6c 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -39,6 +39,40 @@ pub(crate) fn gpu_inflight(var: Option<&str>) -> usize { } } +/// Caps CPU frames in flight (#1615): a whole number of frames, at least 1. Unset, CPU renders +/// keep one frame per thread in flight as before. A heavy comp can need gigabytes of working +/// memory per frame, so 16 threads at once can get the process OOM-killed; the headless CLI +/// reads no `memory.*` preferences, so this is how a render there is held to fewer frames. +pub(crate) const CPU_INFLIGHT_ENV: &str = "EFFECTCRAFT_CPU_INFLIGHT"; + +/// CPU frames in flight from the `EFFECTCRAFT_CPU_INFLIGHT` value: `None` (no cap) when unset or +/// blank, a positive whole number, or `None` with a warning when it is 0, negative, not a +/// number or too large. +pub(crate) fn cpu_inflight(var: Option<&str>) -> Option { + let v = var?; + if v.trim().is_empty() { + return None; + } + match v.trim().parse::() { + Ok(n) if n >= 1 => Some(n), + _ => { + log::warn!(target: "effectcraft_export", "{CPU_INFLIGHT_ENV}={v:?} is not a whole number of frames of at least 1: CPU frames are not capped"); + None + } + } +} + +/// The frames-in-flight cap of a batch, and whether it lapses once the GPU stops for good: +/// a GPU cap alone lapses (the CPU fallback renders in parallel, #1511), a CPU cap never does, +/// and with both the smaller one holds for the whole batch. +pub(crate) fn inflight_cap(gpu: Option, cpu: Option) -> (Option, bool) { + match (gpu, cpu) { + (Some(g), None) => (Some(g), true), + (Some(g), Some(c)) => (Some(g.min(c)), false), + (None, c) => (c, false), + } +} + /// `render` then `encode` over the frames `ks`, results in `ks` order. /// /// Without `cap` every frame renders and encodes in one parallel batch (the CPU path, as @@ -208,10 +242,11 @@ impl<'a> Cx<'a> { } return out; } - // The cap is for the GPU's memory: once it has stopped for good, frames render on the - // CPU in parallel. - let gpu_live = || self.job.accel.is_none_or(|a| a.available()); - map_frames_while(ks, self.gpu_cap(), gpu_live, |k| self.frame(comp, k), f) + // A GPU cap alone is for the GPU's memory: once it has stopped for good, frames render on + // the CPU in parallel. A CPU cap (#1615) holds throughout. + let (cap, lapses) = inflight_cap(self.gpu_cap(), cpu_inflight(std::env::var(CPU_INFLIGHT_ENV).ok().as_deref())); + let live = || !lapses || self.job.accel.is_none_or(|a| a.available()); + map_frames_while(ks, cap, live, |k| self.frame(comp, k), f) } /// GPU frames rendered at once (#879), decided once per batch: with a GPU accelerator every @@ -646,6 +681,31 @@ mod tests { assert_eq!(Cx::new(&job).gpu_cap(), None, "no accelerator"); } + #[test] + fn cpu_inflight_is_off_unless_a_positive_number() { + assert_eq!(cpu_inflight(None), None); + assert_eq!(cpu_inflight(Some("2")), Some(2)); + assert_eq!(cpu_inflight(Some(" 4 ")), Some(4)); + for bad in ["0", "", " ", "-2", "three", "2.5", "99999999999999999999999"] { + assert_eq!(cpu_inflight(Some(bad)), None, "{bad:?}"); + } + } + + #[test] + fn cpu_cap_holds_and_combines_with_the_gpu_cap() { + // #1615: CPU renders had no cap; a GPU cap alone still lapses when the GPU stops. + assert_eq!(inflight_cap(None, None), (None, false)); + assert_eq!(inflight_cap(None, Some(2)), (Some(2), false)); + assert_eq!(inflight_cap(Some(1), None), (Some(1), true)); + assert_eq!(inflight_cap(Some(3), Some(2)), (Some(2), false)); + assert_eq!(inflight_cap(Some(1), Some(4)), (Some(1), false)); + // A CPU cap of 2 on 8 threads renders at most 2 frames at once. + let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap(); + let (out, max) = pool.install(|| run_capped(16, inflight_cap(None, Some(2)).0)); + assert_eq!(out.len(), 16); + assert!(max <= 2, "{max} frames in flight"); + } + #[test] fn gpu_inflight_override_is_a_positive_number_or_the_default() { assert_eq!(gpu_inflight(None), GPU_INFLIGHT_DEFAULT);