From 74ac5a2f2678539e97f75b13b80c7dff83ab3058 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 9 Nov 2026 02:04:15 +0100 Subject: [PATCH] #3335: choose the GPU frames in flight from measured GPU memory, with a Memory & CPU setting --- crates/engine/src/cmd/render_queue.rs | 1 + crates/engine/src/cmd/timeline.rs | 1 + crates/engine/src/render_queue.rs | 7 + crates/engine/src/rq_tests.rs | 31 +++ crates/engine/src/settings/prefs/dialog.rs | 1 + crates/engine/src/settings/prefs/sections.rs | 16 ++ crates/export/src/lib.rs | 5 +- crates/export/src/pipeline.rs | 236 +++++++++++++++++-- crates/gpu/src/context.rs | 19 ++ crates/gpu/src/lib.rs | 5 + crates/gpu/src/memcap.rs | 19 ++ crates/host/src/lib.rs | 1 + crates/render/src/accel.rs | 22 ++ crates/render/src/lib.rs | 2 +- crates/ui-egui/examples/rq_bench.rs | 10 +- crates/ui-egui/src/i18n/ui.rs | 5 + docs/preferences.md | 8 + 17 files changed, 361 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/cmd/render_queue.rs b/crates/engine/src/cmd/render_queue.rs index e9ce1ffab..9881f578f 100644 --- a/crates/engine/src/cmd/render_queue.rs +++ b/crates/engine/src/cmd/render_queue.rs @@ -866,6 +866,7 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result { caches: None, renderer: None, + gpu_frames: s.prefs.memory.gpu_frames(), }; let r = exporter.export(&job, &mut |_, _| true).map_err(EngineError::Other)?; s.toast(format!("Saved preview to {}", r.path)); diff --git a/crates/engine/src/cmd/timeline.rs b/crates/engine/src/cmd/timeline.rs index df804f2ef..691f373de 100644 --- a/crates/engine/src/cmd/timeline.rs +++ b/crates/engine/src/cmd/timeline.rs @@ -148,6 +148,7 @@ fn prerender( caches: None, renderer: None, + gpu_frames: None, }; let r = exporter.export(&job, progress)?; Ok(Prerendered { path: r.path, width: comp.width, height: comp.height, frame_rate: comp.frame_rate, duration: end - start }) diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index 8e50ad3b5..7c10af40c 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -41,6 +41,8 @@ pub struct ExportJob<'a> { /// The compositor the queue item's output modules render on (#3335): the first module /// chooses (measuring a GPU project's first frames) and stores it here, the others follow. pub renderer: Option<&'a Mutex>>, + /// Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once: 1 to 3, `None` for Auto. + pub gpu_frames: Option, } /// What the jobs of one Render Queue run keep between them (#3335): a layer cache per @@ -246,6 +248,8 @@ struct Work { items: Vec>, /// Settings ▸ General ▸ Switches Affect Nested Comps. nested_switches: bool, + /// Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once. + gpu_frames: Option, } /// Where a relative render output goes when there is no default folder and the project is unsaved: @@ -341,6 +345,7 @@ pub(crate) async fn run_items( storage: s.storage_quota.clone(), items, nested_switches: s.prefs.general.switches_affect_nested_comps, + gpu_frames: s.prefs.memory.gpu_frames(), }; run_work(work, shared, notify).await; } @@ -389,6 +394,7 @@ async fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared nested_switches: w.nested_switches, caches: Some(&caches), renderer: Some(&renderer), + gpu_frames: w.gpu_frames, }; let rr = w .exporter @@ -649,6 +655,7 @@ impl Session { storage: self.storage_quota.clone(), items, nested_switches: self.prefs.general.switches_affect_nested_comps, + gpu_frames: self.prefs.memory.gpu_frames(), }; let shared = Arc::new(JobShared::default()); if wait { diff --git a/crates/engine/src/rq_tests.rs b/crates/engine/src/rq_tests.rs index 18e3da711..60fc2b2c3 100644 --- a/crates/engine/src/rq_tests.rs +++ b/crates/engine/src/rq_tests.rs @@ -306,3 +306,34 @@ fn a_queue_run_keeps_its_jobs_warm() { assert_eq!(jobs[0].1, jobs[1].1, "an item's output modules share one choice"); assert_eq!(ends.0.load(Ordering::SeqCst), 1, "the pool is trimmed once, after the run"); } + +#[test] +fn the_gpu_frames_setting_reaches_every_render_queue_job() { + // #3335: Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once is Auto (`None`) or 1–3. + struct Seen(Mutex>>); + impl Exporter for Seen { + fn formats(&self) -> Vec { + OutputFormat::ALL.to_vec() + } + fn export(&self, job: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result { + self.0.lock().unwrap().push(job.gpu_frames); + Ok(ExportResult { path: job.path.to_string(), ..Default::default() }) + } + } + let mut s = demo(); + let ex = Arc::new(Seen(Mutex::new(vec![]))); + s.exporter = Some(ex.clone()); + assert_eq!(s.prefs.memory.gpu_frames_in_flight, "auto"); + let pages = crate::settings::prefs::pages_json(); + assert!(pages.to_string().contains("memory.gpuFramesInFlight"), "the Memory & CPU page lists it"); + for (value, want) in [("auto", None), ("1", Some(1)), ("2", Some(2)), ("3", Some(3))] { + s.execute("prefs.set", json!({"key": "memory.gpuFramesInFlight", "value": value})).unwrap(); + assert_eq!(s.prefs.memory.gpu_frames(), want, "{value}"); + s.execute("renderQueue.add", json!({"output": crate::tests::stubs::tmp_out(&format!("rq/frames_{value}.mp4"))})).unwrap(); + s.execute("renderQueue.render", json!({})).unwrap(); + assert_eq!(ex.0.lock().unwrap().last().copied(), Some(want), "{value}"); + } + // A hand-edited settings file's nonsense is Auto. + s.prefs.memory.gpu_frames_in_flight = "9".into(); + assert_eq!(s.prefs.memory.gpu_frames(), None); +} diff --git a/crates/engine/src/settings/prefs/dialog.rs b/crates/engine/src/settings/prefs/dialog.rs index b00a69ed3..1e79cdd46 100644 --- a/crates/engine/src/settings/prefs/dialog.rs +++ b/crates/engine/src/settings/prefs/dialog.rs @@ -310,6 +310,7 @@ pub fn pages() -> Vec { s("memory.mediaCacheMb", "Footage Frame Cache", Kind::Int(64, 1 << 20, "MB"), true), s("memory.previewCacheMb", "Preview (RAM) Cache", Kind::Int(64, 1 << 20, "MB"), true), s("memory.reduceCacheWhenLow", "Reduce Cache Size When System Is Low on Memory", B, true), + s("memory.gpuFramesInFlight", "GPU Frames Rendered at Once", Kind::Choice(&[("Auto", "auto"), ("1", "1"), ("2", "2"), ("3", "3")]), true), ], }, Page { diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index ac2fab1cc..328f93bf4 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -201,8 +201,24 @@ page!(Memory { /// RAM preview (viewer frame) cache budget (MB). preview_cache_mb: u32 = 3072, reduce_cache_when_low: bool = true, + /// GPU Frames Rendered at Once (Render Queue): `auto` (from the GPU memory a frame + /// measured, 1 to 3) or `1`, `2`, `3`. `EFFECTCRAFT_GPU_INFLIGHT` overrides it. + gpu_frames_in_flight: String = "auto".into(), }); +impl Memory { + /// [`Memory::gpu_frames_in_flight`] as a count: 1 to 3, or `None` for Auto (and anything + /// else, as a hand-edited settings file may hold). + pub fn gpu_frames(&self) -> Option { + match self.gpu_frames_in_flight.trim() { + "1" => Some(1), + "2" => Some(2), + "3" => Some(3), + _ => None, + } + } +} + page!(Video { enable_output: bool = false, device: String = String::new(), diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index e7ffc39ef..d9c315558 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -53,7 +53,7 @@ pub use effectcraft_project::render_queue; pub use effectcraft_project::render_queue::StorageQuota; pub use out::Sink; pub(crate) use pipeline::Cx; -pub use pipeline::{CACHE_BUDGET, RENDERER_ENV, pick_renderer}; +pub use pipeline::{CACHE_BUDGET, GPU_INFLIGHT_MAX, RENDERER_ENV, auto_gpu_frames, pick_renderer}; #[derive(Debug, thiserror::Error)] pub enum ExportError { @@ -119,6 +119,9 @@ pub struct JobOptions<'a> { /// the compositor it chose there, so the item's other output modules encode the same /// frames. `None`: the job decides alone. pub renderer: Option<&'a std::sync::Mutex>>, + /// Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once: 1 to 3, or `None` for Auto (from + /// the device memory a frame measured, #3335). `EFFECTCRAFT_GPU_INFLIGHT` overrides it. + pub gpu_frames: Option, } /// The layer caches of a Render Queue run (see [`JobOptions::caches`]): one per compositor, diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 46fc7b785..649235486 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -13,30 +13,68 @@ use web_time::Instant; use crate::{Job, RenderQuality, out}; -/// GPU frames rendered at once by default: one, as the export scheduler's single GPU worker -/// renders image sequences (#898: frames in flight beyond one only with asynchronous YUV -/// readback). Measured on the #879 project at 1080p on an 8 GB card: 6.3 GB peak with one, -/// 7.0 to 7.8 GB with two, and the whole card with three, where main fell back to the CPU. -/// Not sized from free VRAM: the GPU backend reports no free or total device memory on every -/// platform, so a portable estimate is not available here. +/// GPU frames rendered at once until a frame has measured the device memory, and whenever it +/// can't be measured (#898, #879: measured on the #879 project at 1080p on an 8 GB card, 6.3 GB +/// peak with one frame, 7.0 to 7.8 GB with two, the whole card with three). pub(crate) const GPU_INFLIGHT_DEFAULT: usize = 1; -/// Overrides [`GPU_INFLIGHT_DEFAULT`]: a whole number of frames, at least 1. +/// The most GPU frames Auto renders at once (#3335: four and more measured no faster). +pub const GPU_INFLIGHT_MAX: usize = 3; +/// Overrides Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once: a whole number of frames, +/// at least 1. pub(crate) const GPU_INFLIGHT_ENV: &str = "EFFECTCRAFT_GPU_INFLIGHT"; /// GPU frames in flight from the `EFFECTCRAFT_GPU_INFLIGHT` value: a positive whole number, or -/// the default (with a warning when a value was given) when it is 0, negative, not a number or -/// too large. -pub(crate) fn gpu_inflight(var: Option<&str>) -> usize { - let Some(v) = var else { return GPU_INFLIGHT_DEFAULT }; +/// `None` (with a warning when a value was given) when it is unset, 0, negative, not a number +/// or too large. +pub(crate) fn gpu_inflight_env(var: Option<&str>) -> Option { + let v = var?; match v.trim().parse::() { - Ok(n) if n >= 1 => n, + Ok(n) if n >= 1 => Some(n), _ => { - log::warn!(target: "effectcraft_export", "{GPU_INFLIGHT_ENV}={v:?} is not a whole number of frames of at least 1: using {GPU_INFLIGHT_DEFAULT}"); - GPU_INFLIGHT_DEFAULT + log::warn!(target: "effectcraft_export", "{GPU_INFLIGHT_ENV}={v:?} is not a whole number of frames of at least 1: ignored"); + None } } } +/// Auto's GPU frames in flight from the device memory after a frame (#3335): as many more +/// frames as the budget has room for beyond the allocator's peak (which holds one frame's +/// working memory) and what the caches may still grow by, 1 to [`GPU_INFLIGHT_MAX`]. Unknown +/// memory: 1. +pub fn auto_gpu_frames(m: Option) -> usize { + let Some(m) = m.filter(|m| m.budget > 0 && m.frame > 0 && m.peak > 0) else { return GPU_INFLIGHT_DEFAULT }; + let more = m.budget.saturating_sub(m.peak).saturating_sub(m.reserve) / m.frame; + usize::try_from(more.saturating_add(1)).unwrap_or(GPU_INFLIGHT_MAX).clamp(1, GPU_INFLIGHT_MAX) +} + +/// The GPU frames a job renders at once and why (for the render log): the environment's +/// override, else the setting (`pref`, `None` = Auto), else Auto from `measured` (`None`: no +/// GPU frame has rendered yet, so nothing is settled). +pub(crate) fn resolve_gpu_frames(env: Option<&str>, pref: Option, measured: Option>) -> Option<(usize, String)> { + if let Some(n) = gpu_inflight_env(env) { + return Some((n, format!("set by {GPU_INFLIGHT_ENV}"))); + } + if let Some(n) = pref.filter(|n| *n >= 1) { + return Some((n, "Settings".into())); + } + let m = measured?; + let n = auto_gpu_frames(m); + let mib = |b: u64| b >> 20; + Some(( + n, + match m { + Some(m) => format!( + "Auto: {} MiB per frame, {} MiB peak and {} MiB cache reserve in a {} MiB budget", + mib(m.frame), + mib(m.peak), + mib(m.reserve), + mib(m.budget) + ), + None => "Auto: the GPU's memory is not known".into(), + }, + )) +} + /// Overrides the Render Queue's measured choice of compositor for GPU projects with an /// accelerator (#3335): `gpu` (no measurement, the GPU), `cpu` (Mercury Software Only) or /// `auto` (measure, the default). @@ -211,6 +249,10 @@ pub(crate) struct Cx<'a> { caches: Caches<'a>, /// The compositor the job renders on, once chosen ([`Cx::backend`]). choice: Mutex>, + /// GPU frames rendered at once and why, once settled ([`Cx::gpu_inflight`]). + gpu_frames: Mutex>, + /// A frame has rendered on the GPU compositor: its memory can be measured. + gpu_rendered: std::sync::atomic::AtomicBool, /// (output frame, seconds) of every rendered frame (Plus Per Frame Info logs). pub frame_times: Option>>, /// Files written to an overflow folder. @@ -288,7 +330,17 @@ impl<'a> Cx<'a> { for r in [crate::JobRenderer::Gpu, crate::JobRenderer::Cpu] { caches.of(r).set_gate(job.accel.and_then(|a| a.miss_gate())); } - let cx = Cx { job, project, caches, choice: Mutex::new(None), frame_times, overflowed: Mutex::new(vec![]), written: Mutex::new(vec![]) }; + let cx = Cx { + job, + project, + caches, + choice: Mutex::new(None), + gpu_frames: Mutex::new(None), + gpu_rendered: Default::default(), + frame_times, + overflowed: Mutex::new(vec![]), + written: Mutex::new(vec![]), + }; if let Some(c) = cx.fixed_choice(std::env::var(RENDERER_ENV).ok().as_deref()) { cx.decide(c); } @@ -489,8 +541,13 @@ impl<'a> Cx<'a> { }; let logged = || self.frame_times.as_ref().map_or(0, |ft| ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).len()); let cpu_cap = self.inflight().cpu; - let timed = |on: crate::JobRenderer, frames: &[u64], caps: Inflight| { + let timed = |on: crate::JobRenderer, frames: &[u64]| { let warm = render(first, on); + // The GPU's frames in flight settle with its warm-up frame (Auto measures it). + let caps = match on { + crate::JobRenderer::Gpu => Inflight { gpu: Some(self.gpu_inflight()), cpu: cpu_cap }, + crate::JobRenderer::Cpu => Inflight { gpu: None, cpu: cpu_cap }, + }; let t0 = Instant::now(); let gpu_live = || on == crate::JobRenderer::Cpu || self.job.accel.is_some_and(|a| a.available()); let items = map_frames_while(frames.to_vec(), caps, gpu_live, |k| render(k, on), |k, i| (k, i)); @@ -498,9 +555,9 @@ impl<'a> Cx<'a> { (std::iter::once((first, warm)).chain(items).collect::>(), ms) }; let log0 = logged(); - let (gpu, gpu_ms) = timed(crate::JobRenderer::Gpu, gpu_ks, Inflight { gpu: Some(self.gpu_inflight()), cpu: cpu_cap }); + let (gpu, gpu_ms) = timed(crate::JobRenderer::Gpu, gpu_ks); let log1 = logged(); - let (cpu, cpu_ms) = timed(crate::JobRenderer::Cpu, cpu_ks, Inflight { gpu: None, cpu: cpu_cap }); + let (cpu, cpu_ms) = timed(crate::JobRenderer::Cpu, cpu_ks); // A GPU that stopped meanwhile rendered its frames on the CPU: it is no choice. let gpu_ms = self.job.accel.is_some_and(|a| a.available()).then_some(gpu_ms); let renderer = pick_renderer(gpu_ms, Some(cpu_ms)); @@ -571,9 +628,23 @@ impl<'a> Cx<'a> { (self.job.accel.is_some() && self.backend() == crate::JobRenderer::Gpu).then(|| self.gpu_inflight()) } - /// GPU frames in flight ([`GPU_INFLIGHT_ENV`] or [`GPU_INFLIGHT_DEFAULT`]). + /// GPU frames in flight ([`resolve_gpu_frames`]): [`GPU_INFLIGHT_DEFAULT`] until settled, + /// which Auto is after the job's first GPU frame. fn gpu_inflight(&self) -> usize { - gpu_inflight(std::env::var(GPU_INFLIGHT_ENV).ok().as_deref()) + if let Some((n, _)) = &*self.gpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner) { + return *n; + } + // Measured outside the lock (the allocator report walks every allocation). + let measured = self.gpu_rendered.load(std::sync::atomic::Ordering::Relaxed).then(|| self.job.accel.and_then(|a| a.frame_memory())); + let Some((n, why)) = resolve_gpu_frames(std::env::var(GPU_INFLIGHT_ENV).ok().as_deref(), self.job.options.gpu_frames, measured) else { + return GPU_INFLIGHT_DEFAULT; + }; + let mut slot = self.gpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + slot.get_or_insert_with(|| { + log::info!(target: "effectcraft_export", "{}: {n} GPU frame(s) at once ({why})", self.job.options.label); + (n, why) + }) + .0 } /// The GPU and CPU frames-in-flight caps every frame loop shares (#1615). @@ -599,6 +670,7 @@ impl<'a> Cx<'a> { async fn frame_async_on(&self, comp: &Comp, i: u64, on: crate::JobRenderer) -> Image { let t0 = Instant::now(); + let gpu = on == crate::JobRenderer::Gpu && self.job.accel.is_some(); let img = match self.job.settings.sample(comp, i) { FrameSample::Progressive(t) => self.render_at(t, on).await, FrameSample::Fields { first, second, upper_first } => { @@ -606,6 +678,9 @@ impl<'a> Cx<'a> { if second == first { a } else { interleave(&a, &self.render_at(second, on).await, upper_first) } } }; + if gpu { + self.gpu_rendered.store(true, std::sync::atomic::Ordering::Relaxed); + } let img = self.crop_resize(img); if let Some(ft) = &self.frame_times { ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((i, t0.elapsed().as_secs_f64())); @@ -644,6 +719,7 @@ impl<'a> Cx<'a> { } // A frame rendered off `passes::comp_frame` ends here; on a `None` the fallback renders and ends it. a.frame_end(); + self.gpu_rendered.store(true, std::sync::atomic::Ordering::Relaxed); if let Some(ft) = &self.frame_times { ft.lock().unwrap_or_else(|e| e.into_inner()).push((i, t0.elapsed().as_secs_f64())); } @@ -787,6 +863,11 @@ impl<'a> Cx<'a> { if let Some(c) = self.choice() { s.push_str(&format!("Renderer: {}\n", describe_choice(&c))); } + if self.backend() == crate::JobRenderer::Gpu + && let Some((n, why)) = &*self.gpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + { + s.push_str(&format!("GPU frames rendered at once: {n} ({why})\n")); + } let over = self.overflowed.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !over.is_empty() { s.push_str(&format!("Storage overflow: {} file(s) written to overflow folders (first: {})\n", over.len(), over[0])); @@ -1249,12 +1330,117 @@ mod tests { } #[test] - fn gpu_inflight_override_is_a_positive_number_or_the_default() { - assert_eq!(gpu_inflight(None), GPU_INFLIGHT_DEFAULT); - assert_eq!(gpu_inflight(Some("3")), 3); - assert_eq!(gpu_inflight(Some(" 5 ")), 5); + fn gpu_inflight_override_is_a_positive_number_or_ignored() { + assert_eq!(gpu_inflight_env(None), None); + assert_eq!(gpu_inflight_env(Some("3")), Some(3)); + assert_eq!(gpu_inflight_env(Some(" 5 ")), Some(5)); for bad in ["0", "", "-2", "three", "2.5", "99999999999999999999999"] { - assert_eq!(gpu_inflight(Some(bad)), GPU_INFLIGHT_DEFAULT, "{bad:?}"); + assert_eq!(gpu_inflight_env(Some(bad)), None, "{bad:?}"); + } + } + + #[test] + fn auto_gpu_frames_follow_the_memory_budget() { + // #3335: Auto renders as many GPU frames at once as the budget has room for, 1 to 3. + use effectcraft_render::FrameMemory; + const GIB: u64 = 1 << 30; + let m = |budget: u64, peak: u64, frame: u64, reserve: u64| Some(FrameMemory { budget, peak, frame, reserve }); + // Unknown memory (no allocator report, no budget): one frame. + assert_eq!(auto_gpu_frames(None), 1); + assert_eq!(auto_gpu_frames(m(0, GIB, GIB / 2, 0)), 1); + assert_eq!(auto_gpu_frames(m(3 * GIB, 0, GIB / 2, 0)), 1); + assert_eq!(auto_gpu_frames(m(3 * GIB, GIB, 0, 0)), 1); + // #879 on an 8 GB card: one frame peaked at 6.3 GB, over the 3 GiB budget: one. + assert_eq!(auto_gpu_frames(m(3 * GIB, 63 * GIB / 10, 5 * GIB, 0)), 1); + // Frames that had to wait for memory report the whole budget per frame: one. + assert_eq!(auto_gpu_frames(m(3 * GIB, GIB, 3 * GIB, 0)), 1); + // Room for half a frame more, or room the upload cache may still take: one. + assert_eq!(auto_gpu_frames(m(2 * GIB, 3 * GIB / 2, GIB, 0)), 1); + assert_eq!(auto_gpu_frames(m(3 * GIB, GIB, GIB / 2, 3 * GIB / 2)), 2); + assert_eq!(auto_gpu_frames(m(3 * GIB, GIB, GIB / 2, 2 * GIB)), 1); + // Room for one more frame: two; ample room: three, never more. + assert_eq!(auto_gpu_frames(m(3 * GIB, 2 * GIB, GIB, 0)), 2); + assert_eq!(auto_gpu_frames(m(3 * GIB, GIB / 2, GIB / 4, 0)), 3); + assert_eq!(auto_gpu_frames(m(u64::MAX, 1, 1, 0)), 3); + } + + #[test] + fn gpu_frames_come_from_the_environment_the_setting_or_auto() { + use effectcraft_render::FrameMemory; + let ample = Some(Some(FrameMemory { budget: 3 << 30, peak: 1 << 29, frame: 1 << 28, reserve: 1 << 28 })); + let n = |env, pref, measured| resolve_gpu_frames(env, pref, measured).map(|(n, _)| n); + // Auto: unsettled until a GPU frame rendered, then measured. + assert_eq!(n(None, None, None), None); + assert_eq!(n(None, None, Some(None)), Some(1)); + assert_eq!(n(None, None, ample), Some(3)); + // The setting's 1, 2 or 3, without measuring. + for k in 1..=3 { + assert_eq!(n(None, Some(k), None), Some(k)); + assert_eq!(n(None, Some(k), ample), Some(k)); + } + // The environment overrides both; a bad value is ignored. + assert_eq!(n(Some("2"), Some(1), None), Some(2)); + assert_eq!(n(Some("4"), None, ample), Some(4)); + assert_eq!(n(Some("zero"), Some(1), None), Some(1)); + let (_, why) = resolve_gpu_frames(None, None, ample).unwrap(); + assert_eq!(why, "Auto: 256 MiB per frame, 512 MiB peak and 256 MiB cache reserve in a 3072 MiB budget"); + } + + /// An accelerator reporting `memory` once asked (renders nothing itself). + struct MemAccel(effectcraft_render::FrameMemory); + impl effectcraft_render::Accelerator for MemAccel { + fn name(&self) -> String { + "mem".into() + } + fn comp_frame(&self, _r: &Renderer, _comp: effectcraft_project::ItemId, _t: Tick) -> Option { + None + } + fn supports_effect(&self, _id: &str) -> bool { + false + } + fn effects(&self, _chain: &[effectcraft_render::FxStep], _buf: &effectcraft_effects::Buf, _levels: Option) -> Option { + None + } + fn frame_memory(&self) -> Option { + Some(self.0) + } + } + + #[test] + fn auto_gpu_frames_settle_after_the_first_gpu_frame() { + // #3335: one GPU frame at a time until a frame measured the memory, then Auto's count + // for the rest of the job; the setting skips the measurement. + use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings}; + if std::env::var_os(GPU_INFLIGHT_ENV).is_some() || std::env::var_os(RENDERER_ENV).is_some() { + return; + } + let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::PngSequence)); + let mut p = Project::default(); + p.settings.gpu_acceleration = true; + let comp = Comp::new(16, 8, effectcraft_time::FrameRate::FPS_25, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", effectcraft_color::Label::Sandstone, None, effectcraft_project::ItemKind::Comp(comp.into())); + let accel = MemAccel(effectcraft_render::FrameMemory { budget: 3 << 30, peak: 1 << 29, frame: 1 << 28, reserve: 0 }); + for (pref, want) in [(None, 3), (Some(2), 2), (Some(1), 1)] { + let job = Job { + project: &p, + footage: &effectcraft_render::NoFootage, + expr: None, + accel: Some(&accel), + comp: cid, + settings: &settings, + output: &output, + path: "/c/f_[#####].png", + sink: None, + nested_switches: true, + options: crate::JobOptions { gpu_frames: pref, ..Default::default() }, + }; + let cx = Cx::new(&job); + if pref.is_none() { + assert_eq!(cx.gpu_cap(), Some(1), "before any GPU frame"); + } + let c = cx.comp().unwrap(); + cx.frame_on(c, 0, crate::JobRenderer::Gpu); + assert_eq!(cx.gpu_cap(), Some(want), "setting {pref:?}"); } } diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index e2ab46bde..d074fe213 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -1135,6 +1135,24 @@ impl GpuContext { } } + /// The memory budget (the memory cap: a conservative share of the device, see `memcap`), + /// the allocator's peak, one frame's share of it and what the upload cache may still add. + /// `None` where any is unknown: no allocator report (Metal, WebGPU, GL) or the cap is off. + pub fn frame_memory(&self) -> Option { + let budget = self.memcap.cap(); + let now = self.allocated_bytes(); + self.memcap.note(now); + // The allocator's peak (sampled while frames render) holds a frame's working textures + // at their most; what is gone again after the frame (and at least what the pool keeps + // for the next) is one frame's share. Frames that had to wait for memory: no room. + let peak = self.memcap.peak(); + let frame = if self.memcap.pressed() { budget } else { peak.saturating_sub(now).max(self.pool_retained_bytes() as u64) }; + // The upload cache may still fill up to its budget. + let uploads = self.uploads.lock().map(|u| u.bytes).unwrap_or(0); + let reserve = self.upload_budget().saturating_sub(uploads) as u64; + (budget > 0 && peak > 0 && frame > 0).then_some(effectcraft_render::FrameMemory { budget, peak, frame, reserve }) + } + /// Bytes held by wgpu's device allocator (`0` on backends that do not report). pub(crate) fn allocated_bytes(&self) -> u64 { self.device.generate_allocator_report().map_or(0, |r| r.total_allocated_bytes) @@ -2133,6 +2151,7 @@ impl<'g> Enc<'g> { return; } let allocated = self.g.allocated_bytes(); + self.g.memcap.note(allocated); if allocated <= self.g.memcap.cap() { let _ = self.g.memcap.should_wait(allocated, 0); return; diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index db6061487..b087a2ceb 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -433,6 +433,11 @@ impl Accelerator for Gpu { Some(&self.auto) } + fn frame_memory(&self) -> Option { + self.ctx.check_health().ok()?; + self.ctx.frame_memory() + } + fn render_end(&self) { let (hits, misses) = self.ctx.pool_stats(); let pct = hits.saturating_mul(100) / hits.saturating_add(misses).max(1); diff --git a/crates/gpu/src/memcap.rs b/crates/gpu/src/memcap.rs index 9c745322f..25194a7c2 100644 --- a/crates/gpu/src/memcap.rs +++ b/crates/gpu/src/memcap.rs @@ -40,6 +40,8 @@ pub(crate) struct MemCap { floor: AtomicU64, /// Set after the first over-cap sample where non-reclaimable memory alone exceeds the cap. warned: AtomicBool, + /// The most the allocator held in any sample (what frames in flight are sized by, #3335). + peak: AtomicU64, #[cfg(test)] timeout_ns: AtomicU64, } @@ -59,6 +61,7 @@ impl MemCap { waits: AtomicU64::new(0), floor: AtomicU64::new(0), warned: AtomicBool::new(false), + peak: AtomicU64::new(0), #[cfg(test)] timeout_ns: AtomicU64::new(crate::readback::TIMEOUT.as_nanos() as u64), } @@ -74,6 +77,7 @@ impl MemCap { waits: AtomicU64::new(0), floor: AtomicU64::new(0), warned: AtomicBool::new(false), + peak: AtomicU64::new(0), timeout_ns: AtomicU64::new(self.timeout_ns.load(Relaxed)), } } @@ -82,6 +86,21 @@ impl MemCap { self.cap } + /// Remember an allocator sample: the peak only grows. + pub(crate) fn note(&self, allocated: u64) { + self.peak.fetch_max(allocated, Relaxed); + } + + /// The most the allocator held in any sample so far. + pub(crate) fn peak(&self) -> u64 { + self.peak.load(Relaxed) + } + + /// Whether frames have had to wait for memory (the device ran over the cap). + pub(crate) fn pressed(&self) -> bool { + self.waits.load(Relaxed) > 0 + } + /// Whether to sample the allocator on this call. pub(crate) fn due(&self) -> bool { self.cap != 0 && self.calls.fetch_add(1, Relaxed).is_multiple_of(EVERY) diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 6c5f417ce..3d1384a90 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -64,6 +64,7 @@ impl Exporter for FileExporter { overflow: job.project.render_prefs.overflow_folders.clone(), caches: job.caches.map(|c| effectcraft_export::JobCaches { gpu: &c.gpu, cpu: &c.cpu }), renderer: job.renderer, + gpu_frames: job.gpu_frames, }, nested_switches: job.nested_switches, }; diff --git a/crates/render/src/accel.rs b/crates/render/src/accel.rs index 0f271c59d..7e91c6f62 100644 --- a/crates/render/src/accel.rs +++ b/crates/render/src/accel.rs @@ -31,6 +31,22 @@ impl Backend { } } +/// An accelerator's device memory, to size how many frames an export renders on it at once +/// (see [`Accelerator::frame_memory`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct FrameMemory { + /// Bytes the accelerator may use (a conservative share of the device, not its free memory). + pub budget: u64, + /// The most its allocator has held while frames rendered (one frame's working memory + /// included). + pub peak: u64, + /// Bytes of that peak one frame needed (what it released again), or the whole budget once + /// frames have had to wait for memory. + pub frame: u64, + /// Bytes long-lived caches (uploaded layers) may still grow by. + pub reserve: u64, +} + /// One effect of a GPU effect chain (see [`Accelerator::effects`]). pub struct FxStep<'x> { pub spec: &'static effectcraft_effects::EffectSpec, @@ -91,6 +107,12 @@ pub trait Accelerator: Send + Sync { fn auto_pick(&self) -> Option<&AutoPick> { None } + /// The device memory budget and use, after at least one frame rendered: what an export + /// sizes its frames in flight by. `None` where it isn't known (no allocator report, no + /// budget): the export then renders one frame at a time. + fn frame_memory(&self) -> Option { + None + } /// Whether the accelerator can still render frames. `false` once it has stopped for good /// (its device was lost or retired): every frame it is asked for falls back to the CPU, so /// callers that limit frames in flight for it can stop doing so. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0f3243e60..27fbf8cec 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -46,7 +46,7 @@ mod layers; mod opts; mod pipeline; -pub use accel::{Accelerator, Backend, FxStep, FxTarget}; +pub use accel::{Accelerator, Backend, FrameMemory, FxStep, FxTarget}; pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage, missing_item_problem}; pub use frames::{ adaptive_samples, blend_frames, cineon_frame, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path, diff --git a/crates/ui-egui/examples/rq_bench.rs b/crates/ui-egui/examples/rq_bench.rs index d290210e4..6d470108b 100644 --- a/crates/ui-egui/examples/rq_bench.rs +++ b/crates/ui-egui/examples/rq_bench.rs @@ -121,7 +121,7 @@ fn main() { for d in &dirs { std::fs::create_dir_all(d).expect("output folder"); let path = d.join(format!("f_[####].{format}")); - run(&mut s, "renderQueue.add", json!({"comp": "Shot", "format": format, "output": path.to_string_lossy()})); + run(&mut s, "renderQueue.add", json!({"comp": "Shot", "format": format, "output": path.to_string_lossy(), "log": "plusSettings"})); } let t0 = std::time::Instant::now(); run(&mut s, "renderQueue.render", json!({"wait": true})); @@ -136,6 +136,14 @@ fn main() { let secs = item.render_time.unwrap_or(f64::NAN); let (n, h) = hash_dir(d); println!(" item {}: {:?}, {:.1} ms/frame, {n} files, hash {h:016x}", i + 1, item.status, secs * 1000.0 / frames as f64); + // The render log's account of the compositor and the GPU frames in flight. + let logs = + std::fs::read_dir(d).map(|r| r.filter_map(|e| e.ok()).map(|e| e.path()).filter(|p| p.to_string_lossy().ends_with(".txt")).collect::>()); + for log in logs.unwrap_or_default() { + for line in std::fs::read_to_string(log).unwrap_or_default().lines().filter(|l| l.starts_with("Renderer:") || l.starts_with("GPU frames")) { + println!(" {line}"); + } + } } println!(" queue: {total:.2} s"); } diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 15bf774f9..72ba2a719 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -891,6 +891,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Footage Frame Cache", "フッテージフレームキャッシュ"), ("Preview (RAM) Cache", "プレビュー(RAM)キャッシュ"), ("Reduce Cache Size When System Is Low on Memory", "システムのメモリが不足しているときにキャッシュサイズを減らす"), + ("GPU Frames Rendered at Once", "一度にレンダリングするGPUフレーム数"), ("Video", "ビデオ"), ("Enable Video Preview Output", "ビデオプレビュー出力を有効にする"), ("Video Device", "ビデオデバイス"), @@ -2022,6 +2023,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Footage Frame Cache", "素材帧缓存"), ("Preview (RAM) Cache", "预览(内存)缓存"), ("Reduce Cache Size When System Is Low on Memory", "系统内存不足时减少缓存大小"), + ("GPU Frames Rendered at Once", "GPU 同时渲染的帧数"), ("Video", "视频"), ("Enable Video Preview Output", "启用视频预览输出"), ("Video Device", "视频设备"), @@ -3133,6 +3135,7 @@ const SWEDISH: &[(&str, &str)] = &[ ("Footage Frame Cache", "Bildrutecache för material"), ("Preview (RAM) Cache", "Förhandsgranskningscache (RAM)"), ("Reduce Cache Size When System Is Low on Memory", "Minska cachestorleken när systemet har ont om minne"), + ("GPU Frames Rendered at Once", "GPU-bildrutor som renderas samtidigt"), ("Video", "Video"), ("Enable Video Preview Output", "Aktivera videoutgång för förhandsgranskning"), ("Video Device", "Videoenhet"), @@ -4350,6 +4353,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Footage Frame Cache", "素材影格快取"), ("Preview (RAM) Cache", "預覽(記憶體)快取"), ("Reduce Cache Size When System Is Low on Memory", "系統記憶體不足時減少快取大小"), + ("GPU Frames Rendered at Once", "GPU 同時算圖的影格數"), ("Video", "視訊"), ("Enable Video Preview Output", "啟用視訊預覽輸出"), ("Video Device", "視訊裝置"), @@ -5910,6 +5914,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Recent Projects Shown", "Projetos recentes exibidos"), ("Red", "Vermelho"), ("Reduce Cache Size When System Is Low on Memory", "Reduzir o tamanho do cache quando o sistema estiver com pouca memória"), + ("GPU Frames Rendered at Once", "Quadros da GPU renderizados de uma vez"), ("Remove from favorites", "Remover dos favoritos"), ("Repair", "Reparo"), ("Report Missing Frames", "Relatar quadros ausentes"), diff --git a/docs/preferences.md b/docs/preferences.md index b8bbb2bcf..927b66788 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -82,6 +82,14 @@ Commands (CLI, MCP, control channel): - `memory.layerCacheMb`: Layer Cache (in the browser at most 512 MB, see [web.md](web.md)) - `memory.mediaCacheMb`: Footage Frame Cache (in the browser at most 256 MB) - `memory.previewCacheMb`: Preview (RAM) Cache +- `memory.gpuFramesInFlight`: GPU Frames Rendered at Once (`auto`, `1`, `2`, `3`): how many + frames the Render Queue renders on the GPU at once. Auto measures the GPU memory after the + job's first GPU frame and fits up to 3 frames into the GPU memory cap (by default a + conservative 3 GiB on a discrete card, less when its memory is known, 2 GiB on an integrated + one; `EFFECTCRAFT_GPU_MEMCAP_MB` sets it), keeping room for the upload cache to fill. It + renders one frame at a time where the memory can't be measured (Metal, WebGPU, GL, or the + cap turned off) and once frames have had to wait for memory. The render log names the count. + `EFFECTCRAFT_GPU_INFLIGHT` overrides it. - `threeD.defaultRenderer`: Default 3D Renderer - `labels.N.name` / `labels.N.color`: the 16 label names and colours, used by the Label menu, the timeline, project panel, render queue and viewer handles