diff --git a/crates/engine/src/cmd/render_queue.rs b/crates/engine/src/cmd/render_queue.rs index 6ca5b9ba0..9881f578f 100644 --- a/crates/engine/src/cmd/render_queue.rs +++ b/crates/engine/src/cmd/render_queue.rs @@ -863,6 +863,10 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result { storage: s.storage_quota.as_deref(), label: "Save Current Preview".into(), nested_switches: s.prefs.general.switches_affect_nested_comps, + + 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 fbc11654f..691f373de 100644 --- a/crates/engine/src/cmd/timeline.rs +++ b/crates/engine/src/cmd/timeline.rs @@ -145,6 +145,10 @@ fn prerender( storage: None, label: format!("Pre-render {}", l.name), nested_switches: cx.nested_switches, + + 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 cc141f3fd..7c10af40c 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -34,6 +34,42 @@ pub struct ExportJob<'a> { pub label: String, /// Settings ▸ General ▸ Switches Affect Nested Comps. pub nested_switches: bool, + /// The layer caches of the Render Queue run this job belongs to (#3335): consecutive jobs + /// start warm, and the run trims the accelerator's pooled textures once at its end. `None`: + /// a one-off export (its own caches, trimmed when it ends). + pub caches: Option<&'a RunCaches>, + /// 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 +/// compositor (a buffer an effect made on the GPU is not the CPU's), together at the budget +/// each job's single cache had before. +pub struct RunCaches { + pub gpu: effectcraft_render::LayerCache, + pub cpu: effectcraft_render::LayerCache, +} + +impl Default for RunCaches { + fn default() -> Self { + let half = effectcraft_render::LayerCache::default().budget() / 2; + RunCaches { gpu: effectcraft_render::LayerCache::new(half), cpu: effectcraft_render::LayerCache::new(half) } + } +} + +/// Calls [`effectcraft_render::Accelerator::render_end`] when a Render Queue run ends, however +/// it ends (done, failed, stopped, panicked): its jobs leave the pooled textures to each other. +struct RunEnd<'a>(Option<&'a dyn effectcraft_render::Accelerator>); + +impl Drop for RunEnd<'_> { + fn drop(&mut self) { + if let Some(a) = self.0 { + a.render_end(); + } + } } /// What an export wrote. @@ -212,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: @@ -307,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; } @@ -317,6 +356,8 @@ async fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared let mut s = lock(&shared.state); s.items_total = w.items.len(); } + let caches = RunCaches::default(); + let _run_end = RunEnd(w.accel.as_deref()); for (k, modules) in w.items.iter().enumerate() { let Some((item, _)) = modules.first() else { continue }; if shared.cancel.load(Ordering::Relaxed) { @@ -337,6 +378,7 @@ async fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared // Every output module encodes the same frames (Composition ▸ Add Output Module); the // first module's file is the item's output. let mut r: Result = Err("no output module".into()); + let renderer = Mutex::new(None); for (mi, (mitem, path)) in modules.iter().enumerate() { let name = w.project.item(mitem.comp).map(|i| i.name.clone()).unwrap_or_default(); let index = w.project.render_queue.iter().position(|i| i.id == mitem.id).map_or(0, |i| i + 1); @@ -350,6 +392,9 @@ async fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared storage: w.storage.as_deref(), label: format!("#{index} {name} (output module {})", mi + 1), nested_switches: w.nested_switches, + caches: Some(&caches), + renderer: Some(&renderer), + gpu_frames: w.gpu_frames, }; let rr = w .exporter @@ -610,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 d6bb96831..60fc2b2c3 100644 --- a/crates/engine/src/rq_tests.rs +++ b/crates/engine/src/rq_tests.rs @@ -248,3 +248,92 @@ fn locked_resize_uses_height_for_height_only_output_module_updates() { let comp = s.project.comp(item.comp).unwrap(); assert_eq!(item.output.output_size(comp, &item.settings), (32, 32), "explicit width retains precedence"); } + +#[test] +fn a_queue_run_keeps_its_jobs_warm() { + // #3335: every job started with a fresh layer cache and trimmed the GPU's texture pool when + // it ended, so each item's first frames were cold. A run's jobs share its caches; the pool is + // trimmed once, when the run ends; an item's output modules share one compositor choice. + use std::sync::atomic::{AtomicUsize, Ordering}; + struct Recorder(Mutex, Option)>>); + impl Exporter for Recorder { + fn formats(&self) -> Vec { + OutputFormat::ALL.to_vec() + } + fn export(&self, job: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result { + let caches = job.caches.map(|c| c as *const _ as usize); + let slot = job.renderer.map(|r| { + // The first module chooses; the second sees its choice. + r.lock().unwrap().get_or_insert(effectcraft_render::Backend::Cpu); + r as *const _ as usize + }); + self.0.lock().unwrap().push((caches, slot)); + Ok(ExportResult { path: job.path.to_string(), ..Default::default() }) + } + } + #[derive(Default)] + struct Ends(AtomicUsize); + impl effectcraft_render::Accelerator for Ends { + fn name(&self) -> String { + "ends".into() + } + fn comp_frame(&self, _: &effectcraft_render::Renderer, _: effectcraft_project::ItemId, _: effectcraft_time::Tick) -> Option { + None + } + fn supports_effect(&self, _: &str) -> bool { + false + } + fn effects(&self, _: &[effectcraft_render::FxStep], _: &effectcraft_effects::Buf, _: Option) -> Option { + None + } + fn render_end(&self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + let mut s = demo(); + let ex = Arc::new(Recorder(Mutex::new(vec![]))); + s.exporter = Some(ex.clone()); + let ends = Arc::new(Ends::default()); + s.accel = Some(ends.clone()); + s.execute("renderQueue.add", json!({"output": crate::tests::stubs::tmp_out("rq/warm_a.mp4")})).unwrap(); + s.execute("render.addOutputModule", json!({"format": "png"})).unwrap(); + s.execute("renderQueue.add", json!({"output": crate::tests::stubs::tmp_out("rq/warm_b.mp4")})).unwrap(); + s.execute("renderQueue.render", json!({})).unwrap(); + let jobs = ex.0.lock().unwrap().clone(); + assert_eq!(jobs.len(), 3, "{jobs:?}"); + assert!(jobs.iter().all(|(c, r)| c.is_some() && r.is_some()), "{jobs:?}"); + assert!(jobs.iter().all(|(c, _)| *c == jobs[0].0), "every job of the run shares its caches"); + 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 9827ce754..21b7501fa 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -53,6 +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, GPU_INFLIGHT_MAX, RENDERER_ENV, auto_gpu_frames, pick_renderer}; #[derive(Debug, thiserror::Error)] pub enum ExportError { @@ -107,6 +108,40 @@ pub struct JobOptions<'a> { pub storage: Option<&'a dyn StorageQuota>, /// Overflow folders, in order. pub overflow: Vec, + /// Layer caches shared by the consecutive jobs of a Render Queue run (#3335): the job + /// renders through them instead of fresh ones, and leaves the accelerator's pooled working + /// textures for the next job (whoever holds the caches calls + /// [`effectcraft_render::Accelerator::render_end`] after the last one). `None`: the job + /// starts cold and trims the pool when it ends, as a one-off export does. + pub caches: Option>, + /// The compositor of the job's frames (`Backend::Gpu` or `Backend::Cpu`; `Auto` counts as unset), shared by the output modules of + /// one queue item: when it holds a choice the job renders on it; otherwise the job stores + /// 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, +/// because a buffer an effect made on the GPU is not the CPU's, and neither may stand in for +/// the other. +#[derive(Clone, Copy)] +pub struct JobCaches<'a> { + pub gpu: &'a effectcraft_render::LayerCache, + pub cpu: &'a effectcraft_render::LayerCache, +} + +/// The compositor a job's frames render on: the GPU's (GPU Acceleration, with an +/// accelerator) or the CPU's frame-parallel one (Software Rendering). The whole job renders +/// on one of them, never a mix: the two differ slightly. A GPU project with an accelerator +/// measures both on its first frames and renders the job on the faster (#3335); the choice is +/// in the render log. `EFFECTCRAFT_RQ_RENDERER=gpu|cpu|auto` overrides the measurement. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum JobRenderer { + Gpu, + Cpu, } /// Progress after a batch of frames. @@ -176,7 +211,8 @@ pub fn export(job: &Job, progress: &mut dyn FnMut(&Progress) -> bool) -> Result< pub async fn export_async(job: &Job<'_>, progress: &mut dyn FnMut(&Progress) -> bool) -> Result { let t0 = Instant::now(); let cx = Cx::new(job); - let render = RenderEnd(job.accel); + // A job of a Render Queue run leaves the pooled working textures warm for the next one. + let render = RenderEnd(job.accel.filter(|_| job.options.caches.is_none())); let mut r = export_with(&cx, progress, t0).await; drop(render); if matches!(r, Err(ExportError::Cancelled)) { diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index e0c5cfc87..922b1b4ea 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -13,30 +13,107 @@ 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` (Software Rendering) or +/// `auto` (measure, the default). +pub const RENDERER_ENV: &str = "EFFECTCRAFT_RQ_RENDERER"; + +/// Frames the GPU renders, timed, when a job measures which compositor is faster (after one +/// untimed warm-up frame each: layer cache misses, uploads, kernel builds). +const PROBE_FRAMES: usize = 2; +/// Frames the CPU renders at once, timed, in that measurement: its throughput comes from +/// rendering several frames in parallel, which two frames understate. +const PROBE_CPU_FRAMES: usize = 8; +/// Jobs shorter than this render on the project's compositor without measuring: the frames +/// the slower one renders for the measurement would cost more than they could save. +const PROBE_MIN_JOB: u64 = 12; +/// Bytes each of a job's two layer caches (one per compositor) may hold: together what the +/// job's single cache held before (`LayerCache::default()`). +pub const CACHE_BUDGET: usize = 1 << 29; + +/// The faster compositor from measured milliseconds per frame (`None`: not measured, or the +/// GPU stopped). As the viewer's Auto ([`effectcraft_render::AutoPick::MARGIN`]), the CPU wins +/// only when clearly faster: ties and noise stay on the GPU. +pub fn pick_renderer(gpu_ms: Option, cpu_ms: Option) -> crate::JobRenderer { + match (gpu_ms, cpu_ms) { + (Some(g), Some(c)) if g.is_finite() && c.is_finite() && c < g * effectcraft_render::AutoPick::MARGIN => crate::JobRenderer::Cpu, + (None, Some(_)) => crate::JobRenderer::Cpu, + _ => crate::JobRenderer::Gpu, + } +} + +/// How a job's compositor was chosen, for the render log. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Choice { + pub renderer: crate::JobRenderer, + /// Measured ms per frame on each (`None`: not measured). + pub gpu_ms: Option, + pub cpu_ms: Option, + pub why: &'static str, +} + /// 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 @@ -168,8 +245,14 @@ pub(crate) struct Cx<'a> { pub project: Project, /// Processed layer buffers shared by the job's frames, as the viewer's layer cache: static /// and transform-only layers render once per job (the GPU compositor then uploads them - /// once). Dropped with the job. - cache: LayerCache, + /// once). One per compositor; the job's own (dropped with it) or a Render Queue run's. + 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. @@ -177,6 +260,23 @@ pub(crate) struct Cx<'a> { written: Mutex>, } +/// A job's layer caches: its own, or those of the Render Queue run it belongs to. +enum Caches<'a> { + Own { gpu: Box, cpu: Box }, + Run(crate::JobCaches<'a>), +} + +impl Caches<'_> { + fn of(&self, r: crate::JobRenderer) -> &LayerCache { + match (self, r) { + (Caches::Own { gpu, .. }, crate::JobRenderer::Gpu) => gpu, + (Caches::Own { cpu, .. }, crate::JobRenderer::Cpu) => cpu, + (Caches::Run(c), crate::JobRenderer::Gpu) => c.gpu, + (Caches::Run(c), crate::JobRenderer::Cpu) => c.cpu, + } + } +} + impl<'a> std::ops::Deref for Cx<'a> { type Target = Job<'a>; fn deref(&self) -> &Job<'a> { @@ -222,10 +322,95 @@ impl<'a> Cx<'a> { // layer and effect quantises at it), not just the finished frame (#2022). project.settings.bit_depth = job.settings.color_depth.resolve(project.settings.bit_depth); let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new())); - let cache = LayerCache::default(); + let caches = match job.options.caches { + Some(c) => Caches::Run(c), + None => Caches::Own { gpu: Box::new(LayerCache::new(CACHE_BUDGET)), cpu: Box::new(LayerCache::new(CACHE_BUDGET)) }, + }; // With deferred readbacks, buffers computed from a missed pass's placeholders are not kept. - cache.set_gate(job.accel.and_then(|a| a.miss_gate())); - Cx { job, project, cache, frame_times, overflowed: Mutex::new(vec![]), written: Mutex::new(vec![]) } + 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), + 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); + } + cx + } + + /// The project's compositor: the GPU's for GPU Acceleration with an accelerator. + fn project_renderer(&self) -> crate::JobRenderer { + if self.project.settings.gpu_acceleration && self.job.accel.is_some() { crate::JobRenderer::Gpu } else { crate::JobRenderer::Cpu } + } + + /// The job's compositor when it is decided without measuring: the queue item's first + /// output module's, the project's when there is nothing to choose between (Software Only, + /// no accelerator, deferred readbacks), or `env` ([`RENDERER_ENV`]). `None`: measure. + fn fixed_choice(&self, env: Option<&str>) -> Option { + let fixed = |renderer, why| Some(Choice { renderer, gpu_ms: None, cpu_ms: None, why }); + if self.project_renderer() == crate::JobRenderer::Cpu { + return fixed(crate::JobRenderer::Cpu, "the project's renderer"); + } + if self.deferred() { + return fixed(crate::JobRenderer::Gpu, "the project's renderer"); + } + let shared = self.job.options.renderer.and_then(|s| *s.lock().unwrap_or_else(std::sync::PoisonError::into_inner)); + let shared = match shared { + Some(Backend::Gpu) => Some(crate::JobRenderer::Gpu), + Some(Backend::Cpu) => Some(crate::JobRenderer::Cpu), + Some(Backend::Auto) | None => None, + }; + if let Some(r) = shared { + return Some(Choice { renderer: r, gpu_ms: None, cpu_ms: None, why: "as the queue item's first output module" }); + } + match env.map(|v| v.trim().to_ascii_lowercase()).as_deref() { + Some("gpu") => fixed(crate::JobRenderer::Gpu, "set by EFFECTCRAFT_RQ_RENDERER"), + Some("cpu") => fixed(crate::JobRenderer::Cpu, "set by EFFECTCRAFT_RQ_RENDERER"), + None | Some("" | "auto") => None, + Some(v) => { + log::warn!(target: "effectcraft_export", "{RENDERER_ENV}={v:?} is not gpu, cpu or auto: measuring"); + None + } + } + } + + /// Settle the job's compositor (once): remember it for the queue item's other output + /// modules and say so in the log. + fn decide(&self, c: Choice) { + let mut slot = self.choice.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if slot.is_some() { + return; + } + *slot = Some(c); + if let Some(shared) = self.job.options.renderer { + let mut shared = shared.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if shared.is_none_or(|b| b == Backend::Auto) { + *shared = Some(match c.renderer { + crate::JobRenderer::Gpu => Backend::Gpu, + crate::JobRenderer::Cpu => Backend::Cpu, + }); + } + } + log::info!(target: "effectcraft_export", "{}: {}", self.job.options.label, describe_choice(&c)); + } + + /// The job's choice, once made. + pub(crate) fn choice(&self) -> Option { + *self.choice.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// The compositor frames render on: the choice, or the project's until it is made. + fn backend(&self) -> crate::JobRenderer { + self.choice().map_or_else(|| self.project_renderer(), |c| c.renderer) } pub fn comp(&self) -> Option<&Comp> { @@ -234,12 +419,22 @@ impl<'a> Cx<'a> { /// The renderer of the job's frames: the Render Settings' options, the job's accelerator /// and layer cache. + #[cfg(test)] fn renderer(&self) -> Renderer<'_> { + self.renderer_on(self.backend()) + } + + /// [`Cx::renderer`] on compositor `on`, with that compositor's layer cache. + fn renderer_on(&self, on: crate::JobRenderer) -> Renderer<'_> { let s = self.job.settings; - // The whole job renders on the project's renderer (Mercury GPU Acceleration or Software - // Only), not on whichever compositor Auto timed faster for each frame: the CPU and GPU - // compositors differ slightly, so a per-frame choice made renders differ run to run. - let backend = if self.project.settings.gpu_acceleration { Backend::Gpu } else { Backend::Cpu }; + // The whole job renders on one compositor (the project's, or the faster one measured on + // the first frames, #3335), not on whichever compositor Auto timed faster for each + // frame: the CPU and GPU compositors differ slightly, so a per-frame choice made renders + // differ within a render. + let backend = match on { + crate::JobRenderer::Gpu => Backend::Gpu, + crate::JobRenderer::Cpu => Backend::Cpu, + }; let opts = RenderOpts { scale: self.comp().map(|c| s.effective_scale(c)).unwrap_or_else(|| s.resolution.clamp(0.01, 4.0)), motion_blur: s.motion_blur_override() != SwitchOverride::OffForAll, @@ -258,13 +453,13 @@ impl<'a> Cx<'a> { let mut r = Renderer::new(&self.project, self.job.footage, opts); r.expr = self.job.expr; r.accel = self.job.accel; - r.cache = Some(&self.cache); + r.cache = Some(self.caches.of(on)); r } - async fn render_at(&self, t: Tick) -> Image { + async fn render_at(&self, t: Tick, on: crate::JobRenderer) -> Image { // With deferred GPU readbacks (a browser worker) the frame renders in passes. - effectcraft_render::passes::comp_frame(&self.renderer(), self.job.comp, t).await + effectcraft_render::passes::comp_frame(&self.renderer_on(on), self.job.comp, t).await } /// Whether frames render in passes (an accelerator with deferred readbacks). @@ -283,10 +478,110 @@ impl<'a> Cx<'a> { } return out; } + self.render_frames(ks, |k, on| self.frame_on(comp, k, on), f) + } + + /// `render` then `encode` over the frames `ks` on the job's compositor, results in `ks` + /// order. The first call of a job that still has to choose its compositor measures both on + /// its first frames ([`Cx::probe`]) and keeps the faster one's. + fn render_frames( + &self, + ks: Vec, + render: impl Fn(u64, crate::JobRenderer) -> I + Sync + Send, + encode: impl Fn(u64, I) -> T + Sync + Send, + ) -> Vec { + use rayon::prelude::*; + let probed = self.probe(&ks, &render); // The GPU cap is for the GPU's memory: once it has stopped for good, frames render on the // CPU, in parallel or under the CPU cap (#1615), which holds throughout. let gpu_live = || self.job.accel.is_none_or(|a| a.available()); - map_frames_while(ks, self.inflight(), gpu_live, |k| self.frame(comp, k), f) + let on = self.backend(); + if probed.is_empty() { + return map_frames_while(ks, self.inflight(), gpu_live, |k| render(k, on), encode); + } + let rest: Vec = ks.iter().copied().filter(|k| !probed.iter().any(|(p, _)| p == k)).collect(); + let mut done: Vec<(u64, T)> = probed.into_par_iter().map(|(k, i)| (k, encode(k, i))).collect(); + done.extend(rest.iter().copied().zip(map_frames_while(rest.clone(), self.inflight(), gpu_live, |k| render(k, on), &encode))); + // Back in `ks` order. + let mut out = Vec::with_capacity(ks.len()); + for k in &ks { + if let Some(i) = done.iter().position(|(d, _)| d == k) { + out.push(done.swap_remove(i).1); + } + } + out + } + + /// Choose the job's compositor by timing both on the first frames of `ks` (#3335), as the + /// viewer's Auto does per comp: frame 0 warms each up (layer cache misses, uploads, kernel + /// builds) untimed, then the GPU renders the next [`PROBE_FRAMES`] under its in-flight cap + /// and the CPU up to [`PROBE_CPU_FRAMES`] after them in parallel (its throughput). The CPU + /// may render more frames at once in the job than here, so the measurement leans towards + /// the GPU, as the margin of [`pick_renderer`] does: the CPU is chosen only when clearly + /// faster. Returns + /// the frames the faster one rendered (the slower one's are rendered again); empty when + /// the choice was made already or is made without measuring (a short job). + fn probe(&self, ks: &[u64], render: &(impl Fn(u64, crate::JobRenderer) -> I + Sync + Send)) -> Vec<(u64, I)> { + if self.choice().is_some() { + return vec![]; + } + let total = self.comp().map_or(0, |c| self.job.settings.frame_count(c)); + if total < PROBE_MIN_JOB { + self.decide(Choice { renderer: crate::JobRenderer::Gpu, gpu_ms: None, cpu_ms: None, why: "the project's renderer (too few frames to measure)" }); + return vec![]; + } + // Nothing will be emitted for a skipped batch, so a later batch can still measure. + if ks.is_empty() { + return vec![]; + } + // A resumed sequence can have only one or two missing frames in its first batch. + // Settle before emitting them: measuring a later batch could otherwise switch to the + // CPU after GPU pixels have already been written, including across output modules. + let n = PROBE_FRAMES.min(ks.len().saturating_sub(1) / 2); + if n == 0 { + self.decide(Choice { + renderer: crate::JobRenderer::Gpu, + gpu_ms: None, + cpu_ms: None, + why: "the project's renderer (too few pending frames to measure)", + }); + return vec![]; + } + let (Some(&first), Some(gpu_ks), Some(cpu_ks)) = (ks.first(), ks.get(1..1 + n), ks.get(1 + n..ks.len().min(1 + n + PROBE_CPU_FRAMES))) else { + return vec![]; + }; + 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]| { + 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)); + let ms = t0.elapsed().as_secs_f64() * 1e3 / frames.len().max(1) as f64; + (std::iter::once((first, warm)).chain(items).collect::>(), ms) + }; + let log0 = logged(); + let (gpu, gpu_ms) = timed(crate::JobRenderer::Gpu, gpu_ks); + let log1 = logged(); + 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)); + // The per-frame log keeps the kept frames' times only. + if let Some(ft) = &self.frame_times { + let mut v = ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let (a, b) = if renderer == crate::JobRenderer::Gpu { (log1, v.len()) } else { (log0, log1) }; + if a <= b && b <= v.len() { + v.drain(a..b); + } + } + self.decide(Choice { renderer, gpu_ms, cpu_ms: Some(cpu_ms), why: "measured on the first frames" }); + if renderer == crate::JobRenderer::Gpu { gpu } else { cpu } } /// [`Cx::frames`] for `ks` while `encode` runs beside them on `beside`'s threads, so the @@ -338,9 +633,29 @@ impl<'a> Cx<'a> { /// GPU frames rendered at once (#879), decided once per batch: with a GPU accelerator every /// frame in flight holds its own working textures, so a whole batch rendered at once can /// exhaust VRAM (or shared memory, which crashes instead of falling back). `None` when the - /// frames render on the CPU: no accelerator, or a Software Only project. + /// frames render on the CPU: no accelerator, a Software Only project, or a job that chose + /// the CPU's compositor (#3335), whose frames render in parallel. fn gpu_cap(&self) -> Option { - (self.job.accel.is_some() && self.project.settings.gpu_acceleration).then(|| gpu_inflight(std::env::var(GPU_INFLIGHT_ENV).ok().as_deref())) + (self.job.accel.is_some() && self.backend() == crate::JobRenderer::Gpu).then(|| self.gpu_inflight()) + } + + /// 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 { + 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). @@ -349,20 +664,34 @@ impl<'a> Cx<'a> { } /// Output frame `i`: sampled (fields, pulldown), at the colour depth, cropped and resized. + #[cfg(test)] pub fn frame(&self, comp: &Comp, i: u64) -> Image { - effectcraft_render::passes::block_on(self.frame_async(comp, i)) + self.frame_on(comp, i, self.backend()) + } + + /// [`Cx::frame`] on compositor `on`. + fn frame_on(&self, comp: &Comp, i: u64, on: crate::JobRenderer) -> Image { + effectcraft_render::passes::block_on(self.frame_async_on(comp, i, on)) } /// [`Cx::frame`] as a future (deferred readbacks: the frame renders in passes). pub async fn frame_async(&self, comp: &Comp, i: u64) -> Image { + self.frame_async_on(comp, i, self.backend()).await + } + + 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).await, + FrameSample::Progressive(t) => self.render_at(t, on).await, FrameSample::Fields { first, second, upper_first } => { - let a = self.render_at(first).await; - if second == first { a } else { interleave(&a, &self.render_at(second).await, upper_first) } + let a = self.render_at(first, on).await; + 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())); @@ -374,7 +703,7 @@ impl<'a> Cx<'a> { /// by the accelerator ([`effectcraft_render::Accelerator::comp_frame_yuv420`]). Only for /// frames that need nothing done after the render (progressive, at the project's depth, no /// crop or resize, rendered at exactly `w`×`h`). `None`: render it with [`Cx::frame`]. - pub fn yuv420_frame(&self, comp: &Comp, i: u64, w: u32, h: u32) -> Option> { + fn yuv420_frame(&self, comp: &Comp, i: u64, w: u32, h: u32, on: crate::JobRenderer) -> Option> { if self.deferred() { return None; } @@ -392,7 +721,7 @@ impl<'a> Cx<'a> { return None; } let t0 = Instant::now(); - let r = self.renderer(); + let r = self.renderer_on(on); let a = r.active_accel()?; let planes = a.comp_frame_yuv420(&r, self.job.comp, t, comp.background)?; let (cw, ch) = (w.div_ceil(2) as usize, h.div_ceil(2) as usize); @@ -401,6 +730,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())); } @@ -419,12 +749,9 @@ impl<'a> Cx<'a> { fallback: impl Fn(u64, Image) -> T + Sync + Send, ) -> Vec { // As in `frames`: once the GPU has stopped for good, the rest render in parallel. - let gpu_live = || self.job.accel.is_none_or(|a| a.available()); - map_frames_while( + self.render_frames( ks, - self.inflight(), - gpu_live, - |k| self.yuv420_frame(comp, k, w, h).ok_or_else(|| self.frame(comp, k)), + |k, on| self.yuv420_frame(comp, k, w, h, on).ok_or_else(|| self.frame_on(comp, k, on)), |k, r| match r { Ok(p) => planes(p), Err(img) => fallback(k, img), @@ -544,6 +871,14 @@ impl<'a> Cx<'a> { Ok(r) => s.push_str(&format!("Result: Done ({} frames, {}×{}, {} bytes) in {seconds:.2} s\n", r.frames, r.width, r.height, r.bytes)), Err(e) => s.push_str(&format!("Result: Failed\nError: {e}\n")), } + 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])); @@ -575,6 +910,20 @@ impl<'a> Cx<'a> { } } +/// The render log's account of a job's compositor: which, why, and what was measured. +pub(crate) fn describe_choice(c: &Choice) -> String { + let name = match c.renderer { + crate::JobRenderer::Gpu => "GPU Acceleration", + crate::JobRenderer::Cpu => "Software Rendering", + }; + let ms = |v: Option| v.map_or_else(|| "n/a".to_string(), |v| format!("{v:.1} ms/frame")); + if c.gpu_ms.is_none() && c.cpu_ms.is_none() { + format!("{name} ({})", c.why) + } else { + format!("{name} ({}: GPU {}, CPU {})", c.why, ms(c.gpu_ms), ms(c.cpu_ms)) + } +} + /// Weave two fields: the dominant field's lines (even lines when `upper_first`) from `first`. pub(crate) fn interleave(first: &Image, second: &Image, upper_first: bool) -> Image { let mut out = first.clone(); @@ -860,6 +1209,82 @@ mod tests { assert_eq!(Cx::new(&job).gpu_cap(), None, "no accelerator"); } + /// A resumed sequence can start with one or two missing frames. Once emitted, their + /// compositor must also be used by every later batch and the item's other output modules. + #[test] + fn a_small_first_batch_settles_the_renderer_before_emitting_frames() { + if std::env::var_os(RENDERER_ENV).is_some() { + return; + } + use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings}; + 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 = NoAccel; + for n in [1, 2] { + let shared = Mutex::new(None); + let job = Job { + project: &p, + footage: &effectcraft_render::NoFootage, + expr: None, + accel: Some(&accel), + comp: cid, + settings: &settings, + output: &output, + path: "/r/f_[#####].png", + sink: None, + nested_switches: true, + options: crate::JobOptions { renderer: Some(&shared), ..Default::default() }, + }; + let cx = Cx::new(&job); + let empty = cx.render_frames(Vec::new(), |_, on| on, |_, on| on); + assert!(empty.is_empty()); + assert_eq!(cx.choice(), None, "skipped batches do not choose"); + let first = cx.render_frames((0..n).collect(), |_, on| on, |_, on| on); + assert_eq!(first, vec![crate::JobRenderer::Gpu; n as usize]); + assert_eq!(*shared.lock().unwrap(), Some(Backend::Gpu), "settled before any pixels are emitted"); + let later = cx.render_frames((n..12).collect(), |_, on| on, |_, on| on); + assert!(later.iter().all(|on| *on == crate::JobRenderer::Gpu)); + assert_eq!(Cx::new(&job).backend(), crate::JobRenderer::Gpu, "the next output module follows"); + } + } + + #[test] + fn a_job_that_chose_the_cpu_is_not_capped() { + // #3335: the CPU's frame-parallel compositor is the point of choosing it; a GPU project + // that measured the CPU faster must not keep rendering one frame at a time. + use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings}; + let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::PngSequence)); + let accel = NoAccel; + let mut p = Project::default(); + p.settings.gpu_acceleration = true; + let job = Job { + project: &p, + footage: &effectcraft_render::NoFootage, + expr: None, + accel: Some(&accel), + comp: effectcraft_project::ItemId(0), + settings: &settings, + output: &output, + path: "/c/f_[#####].png", + sink: None, + nested_switches: true, + options: Default::default(), + }; + let cx = Cx::new(&job); + if std::env::var_os(RENDERER_ENV).is_none() { + assert_eq!(cx.choice(), None, "a GPU project measures first"); + assert!(cx.gpu_cap().is_some()); + } + cx.decide(Choice { renderer: crate::JobRenderer::Cpu, gpu_ms: Some(117.0), cpu_ms: Some(68.0), why: "measured on the first frames" }); + assert_eq!(cx.backend(), crate::JobRenderer::Cpu); + assert_eq!(cx.gpu_cap(), None); + assert_eq!(cx.renderer().active_accel().map(|a| a.name()), None, "CPU frames don't use the accelerator"); + assert_eq!(describe_choice(&cx.choice().unwrap()), "Software Rendering (measured on the first frames: GPU 117.0 ms/frame, CPU 68.0 ms/frame)"); + } + #[test] fn cpu_inflight_is_off_unless_a_positive_number() { assert_eq!(cpu_inflight(None), None); @@ -958,12 +1383,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:?}"); } } @@ -1002,7 +1532,7 @@ mod tests { let cx = Cx::new(&job); let comp = cx.comp().unwrap(); cx.frame(comp, 0); - let renders = cx.cache.stats().misses; + let renders = cx.caches.of(cx.backend()).stats().misses; assert!(renders > 0); for i in 1..10 { let img = cx.frame(comp, i); @@ -1012,7 +1542,7 @@ mod tests { uncached.cache = None; assert_eq!(img.data, uncached.comp_frame(cid, t).data, "frame {i}"); } - let stats = cx.cache.stats(); + let stats = cx.caches.of(cx.backend()).stats(); assert_eq!(stats.misses, renders, "the solid rendered on the first frame only"); assert!(stats.hits >= 9, "{stats:?}"); } diff --git a/crates/export/tests/it/h264_gpu_yuv.rs b/crates/export/tests/it/h264_gpu_yuv.rs index 02963ef46..2b285a44e 100644 --- a/crates/export/tests/it/h264_gpu_yuv.rs +++ b/crates/export/tests/it/h264_gpu_yuv.rs @@ -104,6 +104,8 @@ fn project() -> (Project, ItemId) { fn run(p: &Project, cid: ItemId, accel: Option<&dyn Accelerator>, s: &RenderSettings, path: &Path) -> Vec { let om = OutputModule { bitrate_kbps: 4000, ..OutputModule::for_format(OutputFormat::H264) }; let path = path.to_string_lossy().to_string(); + // These tests are about the accelerator's own path: no measuring against the CPU (#3335). + let gpu = std::sync::Mutex::new(Some(effectcraft_render::Backend::Gpu)); let job = Job { project: p, footage: &NoFootage, @@ -114,7 +116,7 @@ fn run(p: &Project, cid: ItemId, accel: Option<&dyn Accelerator>, s: &RenderSett output: &om, path: &path, sink: None, - options: Default::default(), + options: effectcraft_export::JobOptions { renderer: Some(&gpu), ..Default::default() }, nested_switches: true, }; export(&job, &mut |_| true).expect("export"); diff --git a/crates/export/tests/it/render_settings.rs b/crates/export/tests/it/render_settings.rs index 0fd6d471b..39a7e88ff 100644 --- a/crates/export/tests/it/render_settings.rs +++ b/crates/export/tests/it/render_settings.rs @@ -501,3 +501,150 @@ fn a_job_renders_on_one_compositor() { assert_eq!(cpu.frames.load(Ordering::SeqCst), 0); assert_eq!(c[0].get_pixel(10, 44).0, [255, 0, 0, 255], "the CPU drew the red block"); } + +/// A GPU compositor stand-in that renders the frame exactly as the CPU does, after `delay`, and +/// either as it is or solid green (`green`), counting its frames. +struct TimedAccel { + delay: std::time::Duration, + green: bool, + frames: AtomicUsize, +} + +impl TimedAccel { + fn new(delay_ms: u64, green: bool) -> TimedAccel { + TimedAccel { delay: std::time::Duration::from_millis(delay_ms), green, frames: AtomicUsize::new(0) } + } +} + +impl Accelerator for TimedAccel { + fn name(&self) -> String { + "timed".into() + } + fn comp_frame(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option { + self.frames.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(self.delay); + let c = r.project.comp(comp)?; + Some(if self.green { Image::filled(c.width, c.height, [0.0, 1.0, 0.0, 1.0]) } else { r.comp_frame_cpu(comp, t) }) + } + fn supports_effect(&self, _id: &str) -> bool { + false + } + fn effects(&self, _chain: &[FxStep], _buf: &Buf, _levels: Option) -> Option { + None + } +} + +/// The environment forces the compositor or the GPU frames in flight: the measured choice +/// isn't what these tests check then. +fn measurement_overridden() -> bool { + std::env::var_os(effectcraft_export::RENDERER_ENV).is_some() || std::env::var_os("EFFECTCRAFT_GPU_INFLIGHT").is_some() +} + +/// [`run_on`] on a pool of 8 threads (a batch of 8 frames: room to measure both compositors on +/// the first one, whatever the machine), returning the PNGs and the render log. +fn run_pooled(p: &Project, cid: ItemId, s: &RenderSettings, path: &str, options: JobOptions, accel: &dyn Accelerator) -> (Vec, String) { + let om = OutputModule::for_format(OutputFormat::PngSequence); + let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap(); + let options = JobOptions { log: RenderLog::PlusSettings, ..options }; + let (r, files) = pool.install(|| run_on(p, cid, s, &om, path, options, (Some(accel), false))); + let n = r.expect("export").frames as usize; + let log = files.lock().unwrap().iter().find(|(p, _)| p.ends_with("_RenderLog.txt")).map(|(_, d)| String::from_utf8(d.clone()).unwrap()).expect("log"); + ((0..n).map(|i| png(&files, i)).collect(), log) +} + +#[test] +fn a_transfer_bound_gpu_job_renders_on_the_cpu() { + // #3335: a GPU project whose GPU frames are slower than the CPU's renders the whole job on + // the CPU's frame-parallel compositor, measured on its first frames, and says so in the log. + if measurement_overridden() { + return; + } + let (mut p, cid) = project(FrameRate::new(25, 1), |_, _| {}); + p.settings.gpu_acceleration = true; + let s = custom(0.0, 0.8); + let slow = TimedAccel::new(60, true); + let (frames, log) = run_pooled(&p, cid, &s, "/r/a_[#####].png", JobOptions::default(), &slow); + assert_eq!(frames.len(), 20); + // The GPU rendered its warm-up and timed frames only, and none of them is in the output. + assert_eq!(slow.frames.load(Ordering::SeqCst), 3); + for (i, f) in frames.iter().enumerate() { + assert_eq!(f.get_pixel(10, 44).0, [255, 0, 0, 255], "frame {i} is not the CPU's"); + } + assert!(log.contains("Renderer: Software Rendering (measured on the first frames: GPU "), "{log}"); + // The queue item's next output module follows the first one's choice without measuring. + let shared = Mutex::new(None); + let opts = || JobOptions { renderer: Some(&shared), ..Default::default() }; + let slow = TimedAccel::new(60, true); + run_pooled(&p, cid, &s, "/r/b_[#####].png", opts(), &slow); + assert_eq!(*shared.lock().unwrap(), Some(effectcraft_render::Backend::Cpu)); + let again = TimedAccel::new(0, true); + let (frames, log) = run_pooled(&p, cid, &s, "/r/c_[#####].png", opts(), &again); + assert_eq!(again.frames.load(Ordering::SeqCst), 0); + assert!(frames.iter().all(|f| f.get_pixel(10, 44).0 == [255, 0, 0, 255])); + assert!(log.contains("Renderer: Software Rendering (as the queue item's first output module)"), "{log}"); +} + +#[test] +fn a_gpu_job_that_measures_no_faster_on_the_cpu_stays_on_the_gpu() { + if measurement_overridden() { + return; + } + // A slow CPU (a large comp of eight rotating, translucent full-frame layers, composited + // anew every frame) against a GPU that only fills the frame: every frame is the GPU's. + let (mut p, cid) = project(FrameRate::new(25, 1), |p, comp| { + comp.width = 1920; + comp.height = 1080; + let big = p.add_item("Big", Label::Red, None, ItemKind::Solid(Solid { color: [0.2, 0.4, 0.6], width: 1920, height: 1080, pixel_aspect: 1.0 })); + for k in 0..8 { + let mut l = build::layer(p, comp, "Big", LayerSource::Solid { item: big }, (1920, 1080), None); + l.props.prop_mut("transform/rotation").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Scalar(k as f64)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(90.0 + k as f64))]; + l.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(50.0); + comp.layers.insert(0, l); + } + }); + p.settings.gpu_acceleration = true; + let s = custom(0.0, 0.6); + let fast = TimedAccel::new(0, true); + let (frames, log) = run_pooled(&p, cid, &s, "/r/d_[#####].png", JobOptions::default(), &fast); + assert_eq!(frames.len(), 15); + assert_eq!(fast.frames.load(Ordering::SeqCst), 15, "every kept frame, warm-up and measured ones included, is the GPU's"); + assert!(frames.iter().all(|f| f.pixels().all(|px| px.0 == [0, 255, 0, 255]))); + assert!(log.contains("Renderer: GPU Acceleration (measured on the first frames: GPU "), "{log}"); +} + +#[test] +fn gpu_cpu_and_measured_jobs_write_the_same_frames() { + // #3335: where the two compositors agree, a job is the same whichever renders it: the GPU + // (forced), the CPU (Software Only) or the measured choice. + if measurement_overridden() { + return; + } + let (mut p, cid) = project(FrameRate::new(25, 1), |_, _| {}); + let s = custom(0.0, 0.8); + let echo = TimedAccel::new(5, false); + p.settings.gpu_acceleration = false; + let (cpu, log) = run_pooled(&p, cid, &s, "/i/a_[#####].png", JobOptions::default(), &echo); + assert!(log.contains("Renderer: Software Rendering (the project's renderer)"), "{log}"); + p.settings.gpu_acceleration = true; + let gpu_slot = Mutex::new(Some(effectcraft_render::Backend::Gpu)); + let (gpu, _) = run_pooled(&p, cid, &s, "/i/b_[#####].png", JobOptions { renderer: Some(&gpu_slot), ..Default::default() }, &echo); + let (auto, _) = run_pooled(&p, cid, &s, "/i/c_[#####].png", JobOptions::default(), &echo); + assert_eq!(cpu.len(), 20); + assert_eq!(cpu, gpu); + assert_eq!(cpu, auto); +} + +#[test] +fn renderer_choice_follows_the_measurement() { + use effectcraft_export::{JobRenderer, pick_renderer}; + assert_eq!(pick_renderer(Some(117.0), Some(68.0)), JobRenderer::Cpu); + assert_eq!(pick_renderer(Some(68.0), Some(117.0)), JobRenderer::Gpu); + // Ties and noise stay on the GPU: the CPU must be clearly faster. + assert_eq!(pick_renderer(Some(100.0), Some(90.0)), JobRenderer::Gpu); + assert_eq!(pick_renderer(Some(100.0), Some(80.0)), JobRenderer::Cpu); + // Unmeasured or nonsense: the GPU, unless the GPU stopped while measuring. + assert_eq!(pick_renderer(None, None), JobRenderer::Gpu); + assert_eq!(pick_renderer(Some(f64::NAN), Some(1.0)), JobRenderer::Gpu); + assert_eq!(pick_renderer(None, Some(50.0)), JobRenderer::Cpu); +} diff --git a/crates/gpu/src/adv3d.rs b/crates/gpu/src/adv3d.rs index b88322316..6c9e8b6a6 100644 --- a/crates/gpu/src/adv3d.rs +++ b/crates/gpu/src/adv3d.rs @@ -801,7 +801,7 @@ fn read_buffer(e: &mut Enc, buf: &wgpu::Buffer, size: u64) -> Option> { }); e.record(&[]).copy_buffer_to_buffer(buf, 0, &rb, 0, size); e.submit(); - let out = e.g.read_buffer(&rb, row, row, 1).map_err(|e| log::error!("gpu 3d readback: {e}")).ok()?; + let out = e.g.read_buffer_after(&rb, e.last_submission(), row, row, 1).map_err(|e| log::error!("gpu 3d readback: {e}")).ok()?; Some(out) } diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 1f04fa5b1..d074fe213 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -128,6 +128,11 @@ const POOL_BUDGET: usize = 256 << 20; /// miss (a new allocation), never a wrong pixel. const STALE_FRAMES: u64 = 2; +/// The pool owner of uploaded textures: never registered by an [`Enc`] (their ids start at 1), +/// so an upload takes only from the shared free list ([`Pool::take`], textures no open encoder +/// can read) and gives its texture back through the guarded [`Pool::release`]. +const UPLOAD_OWNER: u64 = 0; + /// Readback staging buffers kept for reuse across sizes (bytes). const STAGING_BUDGET: u64 = 256 << 20; @@ -693,6 +698,10 @@ pub struct GpuContext { dummy_tex: wgpu::TextureView, dummy_buf: wgpu::Buffer, uploads: Mutex, + /// Uploaded footage pictures, shared by the layers that show them (see [`crate::upload`]). + pictures: Mutex, + /// Reused staging buffers of large uploads (see [`crate::upload::Staging`]). + upload_staging: crate::upload::Staging, upload_budget: std::sync::atomic::AtomicUsize, upload_full_warned: std::sync::atomic::AtomicBool, pool: Arc>, @@ -1017,6 +1026,8 @@ impl GpuContext { dummy, dummy_buf, uploads: Mutex::new(Uploads::default()), + pictures: Default::default(), + upload_staging: Default::default(), upload_budget: std::sync::atomic::AtomicUsize::new(UPLOAD_BUDGET), upload_full_warned: Default::default(), pool, @@ -1124,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) @@ -1132,11 +1161,11 @@ impl GpuContext { /// Bytes held in long-lived or still-referenced GPU caches/leases that submitting and waiting /// on `owner` (`closing_enc`) cannot free. fn kept_bytes(&self, owner: u64, closing_enc: Option) -> u64 { - let uploads = self.uploads.lock().map(|u| u.bytes as u64).unwrap_or(0); + // (uploaded layer and footage textures are pooled: the pool's leases count them) let pool = self.pool.lock().map(|p| p.kept_after_flush(owner, closing_enc) as u64).unwrap_or(0); let staging: u64 = self.staging.lock().map(|s| s.iter().map(|(k, v)| k * v.len() as u64).sum()).unwrap_or(0); let zeros: u64 = self.zeros.lock().map(|z| z.keys().map(|&(w, h)| w as u64 * h as u64 * 16).sum()).unwrap_or(0); - uploads + pool + staging + zeros + pool + staging + self.upload_staging.bytes() + zeros } #[cfg(test)] @@ -1198,18 +1227,99 @@ impl GpuContext { /// never consumes the readback deadline or holds the mutex across the whole timeout. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn read_buffer(&self, buffer: &wgpu::Buffer, row: usize, stride: usize, height: usize) -> Result, String> { + self.read_buffer_after(buffer, None, row, stride, height) + } + + /// [`Self::read_buffer`] waiting only for `submission` (the one that wrote `buffer`), not + /// for every submission so far: another frame's GPU work submitted meanwhile doesn't hold + /// this readback up (#3335). `None`: wait for all submitted work. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn read_buffer_after( + &self, + buffer: &wgpu::Buffer, + submission: Option, + row: usize, + stride: usize, + height: usize, + ) -> Result, String> { + self.read_mapped(buffer, submission, |view| crate::readback::packed(view, row, stride, height)) + } + + /// Map `buffer`, wait for the mapping (only for `submission` when given) and hand the + /// mapped bytes to `read`, on this thread, before unmapping: `read` copies the rows + /// straight into their destination, once (#3335). The copy happens outside `poll_lock`, so + /// it doesn't hold up other threads' readbacks the way copying inside the map callback + /// (which runs during a device poll) would. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn read_mapped( + &self, + buffer: &wgpu::Buffer, + submission: Option, + read: impl FnOnce(&[u8]) -> Result, + ) -> Result { let (tx, rx) = std::sync::mpsc::channel(); - self.map_readback(buffer, row, stride, height, move |r| { + let ticket = self.readbacks.start(move |r| { let _ = tx.send(r); + })?; + if !ticket.active() { + return Err("GPU readback cancelled".into()); + } + let b = buffer.clone(); + buffer.map_async(wgpu::MapMode::Read, .., move |r| { + // Timed out or cancelled: nobody reads the mapping. + if !ticket.active() { + b.unmap(); + return; + } + // Mapped: the waiting thread reads the rows and unmaps (an empty vector = mapped). + ticket.complete(r.map(|_| Vec::new()).map_err(|e| format!("GPU buffer mapping: {e}"))); }); + // On any failure, unmap (harmless when not mapped; cancels a pending map) so a mapped + // buffer can never be handed out again. + let fail = |e: String| { + buffer.unmap(); + e + }; + self.wait_mapped(&rx, submission).map_err(fail)?.map_err(fail)?; + let result = { + let view = buffer.get_mapped_range(..).map_err(|e| format!("GPU mapped range: {e}")); + view.and_then(|view| read(&view)) + }; + buffer.unmap(); + result + } + + /// Poll until `rx` reports the mapping (see [`Self::read_mapped`]): polling and receiving + /// share one deadline, started once `poll_lock` is first acquired and polled in + /// [`Self::POLL_SLICE`] slices so lock contention never consumes the readback deadline or + /// holds the mutex across the whole timeout. While another thread holds `poll_lock` it is + /// polling the device, which delivers this mapping too: wait for it instead of queueing. + #[cfg(not(target_arch = "wasm32"))] + fn wait_mapped( + &self, + rx: &std::sync::mpsc::Receiver, String>>, + submission: Option, + ) -> Result, String>, String> { let mut deadline = None; loop { self.readbacks.check()?; if let Ok(result) = rx.try_recv() { - return result; + return Ok(result); } let (remaining, slice, poll_res) = { - let _guard = self.poll_lock.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = match self.poll_lock.try_lock() { + Ok(g) => g, + Err(std::sync::TryLockError::Poisoned(e)) => e.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => { + match rx.recv_timeout(std::time::Duration::from_millis(1)) { + Ok(result) => return Ok(result), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(e) => return Err(format!("GPU readback completion channel: {e}")), + } + // The ticket's own deadline still bounds a wait that never gets the lock. + continue; + } + }; self.readbacks.check()?; let d = *deadline.get_or_insert_with(|| std::time::Instant::now() + crate::readback::TIMEOUT); let remaining = d.saturating_duration_since(std::time::Instant::now()); @@ -1217,12 +1327,12 @@ impl GpuContext { (remaining, std::time::Duration::ZERO, Ok(wgpu::PollStatus::Poll)) } else { let slice = remaining.min(Self::POLL_SLICE); - let res = self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(slice) }); + let res = self.device.poll(wgpu::PollType::Wait { submission_index: submission.clone(), timeout: Some(slice) }); (remaining, slice, res) } }; if let Ok(result) = rx.try_recv() { - return result; + return Ok(result); } if remaining.is_zero() { let error = "GPU readback receive timed out; context retired".to_string(); @@ -1241,7 +1351,7 @@ impl GpuContext { } // The callback may be running on another thread that polled the same device. match rx.recv_timeout(remaining.min(std::time::Duration::from_millis(1))) { - Ok(result) => return result, + Ok(result) => return Ok(result), Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} Err(e) => return Err(format!("GPU readback completion channel: {e}")), } @@ -1253,6 +1363,28 @@ impl GpuContext { Err("synchronous GPU readback is unavailable in the browser".into()) } + #[cfg(target_arch = "wasm32")] + pub(crate) fn read_buffer_after( + &self, + _buffer: &wgpu::Buffer, + _submission: Option, + _row: usize, + _stride: usize, + _height: usize, + ) -> Result, String> { + Err("synchronous GPU readback is unavailable in the browser".into()) + } + + #[cfg(target_arch = "wasm32")] + pub(crate) fn read_mapped( + &self, + _buffer: &wgpu::Buffer, + _submission: Option, + _read: impl FnOnce(&[u8]) -> Result, + ) -> Result { + Err("synchronous GPU readback is unavailable in the browser".into()) + } + /// Switch to deferred readbacks (see [`crate::deferred`]): readbacks are never waited for. pub fn set_deferred(&mut self, on: bool) { self.readbacks.cancel_pending("GPU readback mode changed".into()); @@ -1446,6 +1578,8 @@ impl GpuContext { dummy: self.dummy.clone(), dummy_buf: self.dummy_buf.clone(), uploads: Mutex::new(Uploads::default()), + pictures: Default::default(), + upload_staging: Default::default(), upload_budget: std::sync::atomic::AtomicUsize::new(UPLOAD_BUDGET), upload_full_warned: Default::default(), pool: self.pool.clone(), @@ -1625,16 +1759,54 @@ impl GpuContext { return None; } self.transfers.count(true, img.data.len() * 16); - let g = self.image(img.width, img.height); - self.queue.write_texture( - wgpu::TexelCopyTextureInfo { texture: &g.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, - bytemuck::cast_slice(&img.data), - wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(img.width * 16), rows_per_image: Some(img.height) }, - wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 }, - ); + // A texture from the pool's shared free list: no open encoder can still read it, and the + // copy below runs before anything recorded after this call. + let (g, _) = self.pooled(img.width, img.height, UPLOAD_OWNER); + if !self.upload_staging.write(&self.device, &self.queue, img, &g.texture) { + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { texture: &g.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + bytemuck::cast_slice(&img.data), + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(img.width * 16), rows_per_image: Some(img.height) }, + wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 }, + ); + } Some(g) } + /// Upload a decoded footage picture once while the footage source keeps it: every layer + /// and effect input showing the same frame shares the texture (`Renderer::footage_picture`). + pub(crate) fn upload_picture(&self, img: &Arc) -> Option { + if let Some(t) = self.pictures.lock().ok().and_then(|mut p| p.get(img)) { + return Some(t); + } + let tex = self.upload_image(img)?; + if let Ok(mut p) = self.pictures.lock() { + p.insert(img, tex.clone(), crate::upload::PICTURE_BUDGET.min(self.upload_budget() / 4)); + } + Some(tex) + } + + /// Entries in the footage picture cache. + #[cfg(test)] + pub(crate) fn picture_entries(&self) -> usize { + self.pictures.lock().map(|p| p.len()).unwrap_or_default() + } + + /// The upload staging buffers (tests). + #[cfg(test)] + pub(crate) fn upload_staging(&self) -> &crate::upload::Staging { + &self.upload_staging + } + + /// A render is done: drop the footage picture textures and the upload staging buffers + /// (the next render's pictures upload again; their textures come from the pool). + pub fn trim_uploads(&self) { + if let Ok(mut p) = self.pictures.lock() { + p.clear(); + } + self.upload_staging.clear(); + } + /// Entries in the layer upload cache. #[cfg(test)] pub(crate) fn upload_entries(&self) -> usize { @@ -1696,6 +1868,10 @@ impl GpuContext { u.map.clear(); u.bytes = 0; } + if let Ok(mut p) = self.pictures.lock() { + p.clear(); + } + self.upload_staging.clear(); if let Ok(mut p) = self.pool.lock() { p.clear(); } @@ -1952,6 +2128,11 @@ impl<'g> Enc<'g> { dst } + /// The last submission of this encoder's work (what a readback after it waits for). + pub(crate) fn last_submission(&self) -> Option { + self.last_submit.clone() + } + /// Submit recorded work. pub fn submit(&mut self) { if let Some((enc, id)) = self.enc.take() { @@ -1970,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; @@ -1994,13 +2176,13 @@ impl<'g> Enc<'g> { let layout = crate::readback::Layout::new(img.width, img.height, 16, self.g.device.limits().max_buffer_size) .map_err(|e| log::error!("gpu image readback: {e}")) .ok()?; - let mut data = Vec::new(); - data.try_reserve_exact(layout.len / 16).map_err(|e| log::error!("gpu image allocation: {e}")).ok()?; - data.resize(layout.len / 16, [0.0; 4]); - let mut out = Image { width: img.width, height: img.height, data }; - let dst: &mut [u8] = bytemuck::cast_slice_mut(&mut out.data); - self.read_texture_into(&img.texture, [0, 0, img.width, img.height], 16, dst)?; - Some(out) + // The mapped rows go straight into the image's pixels: one copy (#3335). + let data: Vec<[f32; 4]> = self.read_texture_with(&img.texture, [0, 0, img.width, img.height], 16, crate::readback::rows::<[f32; 4]>)?; + if data.len() != layout.len / 16 { + log::error!("gpu image readback size mismatch"); + return None; + } + Some(Image { width: img.width, height: img.height, data }) } /// Tightly packed texel bytes of a texture (`bpp` bytes per pixel). @@ -2013,27 +2195,32 @@ impl<'g> Enc<'g> { pub fn read_rect(&mut self, texture: &wgpu::Texture, x: u32, y: u32, w: u32, h: u32, bpp: u32) -> Option> { let layout = crate::readback::Layout::new(w, h, bpp, self.g.device.limits().max_buffer_size).map_err(|e| log::error!("gpu texture readback: {e}")).ok()?; - let mut out = Vec::new(); - out.try_reserve_exact(layout.len).map_err(|e| log::error!("gpu readback allocation: {e}")).ok()?; - out.resize(layout.len, 0); - self.read_texture_into(texture, [x, y, w, h], bpp, &mut out)?; + let out: Vec = self.read_texture_with(texture, [x, y, w, h], bpp, crate::readback::rows::)?; + if out.len() != layout.len { + log::error!("gpu texture readback size mismatch"); + return None; + } Some(out) } - /// [`Enc::read_rect`] into `out` (`w × bpp × h` bytes), through a reused staging buffer. - /// `rect` is `[x, y, w, h]`. - fn read_texture_into(&mut self, texture: &wgpu::Texture, rect: [u32; 4], bpp: u32, out: &mut [u8]) -> Option<()> { + /// Read `rect` (`[x, y, w, h]`) of `texture` back through a reused staging buffer: `read` + /// gets the mapped bytes, the row length, the padded row stride and the row count, and + /// copies the rows into their destination itself (one copy, #3335). Waits only for this + /// encoder's own submission, so frames rendering on other threads don't delay it. + fn read_texture_with( + &mut self, + texture: &wgpu::Texture, + rect: [u32; 4], + bpp: u32, + read: impl FnOnce(&[u8], usize, usize, usize) -> Result, + ) -> Option { let [x, y, w, h] = rect; if !self.g.can_wait() { return None; } - self.g.transfers.count(false, out.len()); let layout = crate::readback::Layout::new(w, h, bpp, self.g.device.limits().max_buffer_size).map_err(|e| log::error!("gpu texture readback: {e}")).ok()?; - if out.len() != layout.len { - log::error!("gpu readback destination size mismatch"); - return None; - } + self.g.transfers.count(false, layout.len); let row = layout.row as usize; let padded = layout.stride; let size = layout.size; @@ -2051,12 +2238,11 @@ impl<'g> Enc<'g> { wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, ); self.submit(); - let bytes = self.g.read_buffer(&buf, row, padded as usize, h as usize).map_err(|e| log::error!("gpu texture readback: {e}")).ok()?; - if bytes.len() != out.len() { - log::error!("gpu texture readback size mismatch"); - return None; - } - out.copy_from_slice(&bytes); + let out = self + .g + .read_mapped(&buf, self.last_submit.clone(), |view| read(view, row, padded as usize, h as usize)) + .map_err(|e| log::error!("gpu texture readback: {e}")) + .ok()?; // Keep a few staging buffers per size (frames read back at the same size every time). // Layers whose bounds change every frame read back at a new size each time: drop the // other sizes once over budget, or mapped host memory grows for the whole render (#2081). @@ -2070,7 +2256,7 @@ impl<'g> Enc<'g> { v.push(buf); } } - Some(()) + Some(out) } /// [`Enc::read_texture`] without waiting for the GPU (the browser's main thread can't): `done` diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 3ede5c6b9..b087a2ceb 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -84,6 +84,7 @@ mod memcap; mod ops; mod particles; mod readback; +mod upload; mod walk; mod yuv; @@ -432,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); @@ -439,6 +445,7 @@ impl Accelerator for Gpu { target: "effectcraft_gpu", "gpu pool: {hits} working textures reused, {misses} allocated ({pct}% reuse) so far" ); + self.ctx.trim_uploads(); self.ctx.trim_pool(); } 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/gpu/src/readback.rs b/crates/gpu/src/readback.rs index b9d0a28a8..290ae08de 100644 --- a/crates/gpu/src/readback.rs +++ b/crates/gpu/src/readback.rs @@ -367,20 +367,32 @@ thread_local! { /// Validate every row before publishing tightly packed bytes. pub(crate) fn packed(view: &[u8], row: usize, stride: usize, height: usize) -> Result, Error> { - if row == 0 || height == 0 || stride < row { + rows(view, row, stride, height) +} + +/// The `height` rows of `row` bytes each, `stride` bytes apart in the mapped `view`, as tightly +/// packed `T`s: one copy, straight from the mapped rows into a vector reserved once (no +/// zero-filled buffer first, no intermediate byte vector). Every row is validated before any +/// is copied. A row that is not aligned for `T` is decoded value by value (same bytes). +pub(crate) fn rows(view: &[u8], row: usize, stride: usize, height: usize) -> Result, Error> { + let unit = std::mem::size_of::().max(1); + if row == 0 || height == 0 || stride < row || !row.is_multiple_of(unit) { return Err("invalid GPU readback layout".into()); } - let size = row.checked_mul(height).ok_or("GPU readback output size overflow")?; + let size = row.checked_mul(height).ok_or("GPU readback output size overflow")? / unit; let end = (height - 1).checked_mul(stride).and_then(|s| s.checked_add(row)).ok_or("GPU readback mapped size overflow")?; if view.len() < end { return Err(format!("short GPU readback: {} bytes, needs {end}", view.len())); } - let mut out = Vec::new(); + let mut out: Vec = Vec::new(); out.try_reserve_exact(size).map_err(|e| format!("allocating GPU readback output: {e}"))?; for y in 0..height { let start = y.checked_mul(stride).ok_or("GPU readback row overflow")?; - let bytes = view.get(start..start + row).ok_or("short GPU readback row")?; - out.extend_from_slice(bytes); + let bytes = start.checked_add(row).and_then(|e| view.get(start..e)).ok_or("short GPU readback row")?; + match bytemuck::try_cast_slice::(bytes) { + Ok(values) => out.extend_from_slice(values), + Err(_) => out.extend(bytes.chunks_exact(unit).map(bytemuck::pod_read_unaligned::)), + } } Ok(out) } @@ -658,4 +670,28 @@ mod tests { assert!(packed(&[0; 7], 4, 4, 2).is_err()); assert_eq!(packed(&[1, 2, 0, 0, 3, 4], 2, 4, 2).unwrap(), vec![1, 2, 3, 4]); } + + /// #3335: rows go from the mapped buffer straight into the destination type, skipping the + /// row padding, whether or not the mapped bytes are aligned for it. + #[test] + fn rows_copy_once_into_the_destination_type() { + // Two rows of two RGBA f32 pixels, padded to a 48-byte stride. + let px = |i: f32| [i, i + 0.25, i + 0.5, 1.0]; + let want = vec![px(1.0), px(2.0), px(3.0), px(4.0)]; + let mut view = Vec::new(); + for r in want.chunks(2) { + view.extend_from_slice(bytemuck::cast_slice(r)); + view.extend_from_slice(&[0xAB; 16]); + } + assert_eq!(rows::<[f32; 4]>(&view, 32, 48, 2).unwrap(), want); + // The same bytes one byte off any f32 alignment decode the same. + let mut shifted = vec![0u8]; + shifted.extend_from_slice(&view); + assert_eq!(rows::<[f32; 4]>(&shifted[1..], 32, 48, 2).unwrap(), want); + // A row that isn't whole pixels, a short last row and a stride under the row fail. + assert!(rows::<[f32; 4]>(&view, 30, 48, 2).is_err()); + assert!(rows::<[f32; 4]>(&view[..view.len() - 17], 32, 48, 2).is_err()); + assert!(rows::<[f32; 4]>(&view, 32, 16, 2).is_err()); + assert_eq!(rows::(&[1, 2, 9, 3, 4], 2, 3, 2).unwrap(), vec![1, 2, 3, 4]); + } } diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index b88bac0be..9ab9fd49d 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -28,8 +28,10 @@ use crate::Gpu; mod adjust; mod adv3d; +mod bench_share; mod deferred; mod deferred_jobs; +mod footage_share; mod fx_color; mod fx_depth; mod fx_distort; @@ -1247,6 +1249,49 @@ fn readback_staging_stays_bounded_across_sizes() { assert!(held <= (256 << 20) + 2 * 1024 * 16 * 376, "staging buffers hold {held} bytes"); } +/// #3335: a readback copies the mapped rows straight into the image (no zero-filled buffer, +/// no intermediate byte vector), skipping each row's padding: odd widths, sub-rectangles and +/// readbacks from several threads at once (each waiting for its own submission) return the +/// uploaded pixels exactly. +#[test] +fn single_copy_readback_returns_the_exact_pixels() { + let Some(g) = gpu() else { return }; + if !g.ctx.can_wait() { + return; + } + let image = |w: u32, h: u32, seed: f32| { + let mut img = Image::new(w, h); + for (i, p) in img.data.iter_mut().enumerate() { + let v = i as f32 * 0.37 + seed; + *p = [v, -v, v * 1e-3, 1.0 / (1.0 + v)]; + } + img + }; + // 37 × 16 bytes = 592 per row: padded to 768 in the staging buffer. + let src = image(37, 5, 0.5); + let up = g.ctx.upload_image(&src).expect("upload"); + let mut e = crate::context::Enc::new(&g.ctx); + let back = e.download(&up).expect("download"); + assert_eq!((back.width, back.height), (37, 5)); + assert_eq!(back.data, src.data); + let rect = e.read_rect(&up.texture, 3, 1, 5, 2, 16).expect("read_rect"); + let want: Vec<[f32; 4]> = (1..3).flat_map(|y| (3..8).map(move |x| (x, y))).map(|(x, y)| src.data[src.idx(x, y)]).collect(); + assert_eq!(rect, bytemuck::cast_slice::<[f32; 4], u8>(&want)); + // Frames on several threads, each with its own encoder and submission. + std::thread::scope(|s| { + for t in 0..4 { + s.spawn(move || { + for k in 0..3 { + let src = image(61 + t, 7 + k, (t * 10 + k) as f32); + let up = g.ctx.upload_image(&src).expect("upload"); + let back = crate::context::Enc::new(&g.ctx).download(&up).expect("download"); + assert_eq!(back.data, src.data, "thread {t} frame {k}"); + } + }); + } + }); +} + /// A static adjustment layer with a time-dependent effect (Noise) keeps its footprint key /// across frames (the footprint is the source through the masks, not the effects), so the GPU /// uploads nothing after the first frame; matches the CPU. diff --git a/crates/gpu/src/tests/bench_share.rs b/crates/gpu/src/tests/bench_share.rs new file mode 100644 index 000000000..580c7f4e6 --- /dev/null +++ b/crates/gpu/src/tests/bench_share.rs @@ -0,0 +1,83 @@ +//! Warm-frame benchmark of #3333 (run by hand): three layers of the same 1080p footage frame, +//! 32 bpc, a 30-frame sequence already decoded (as the media pool serves a warm frame), with a +//! layer cache as the Render Queue has. Prints the median and mean ms per frame and a hash of +//! every frame (to compare the pixels of two builds). +//! +//! `cargo test -p effectcraft-gpu --release --lib bench_share -- --ignored --nocapture` + +use std::collections::HashMap; +use std::sync::Mutex; + +use super::*; + +struct Decoded { + frames: Mutex>>, +} + +impl FootageSource for Decoded { + fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option> { + let i = f.frame_rate.frame_at(t).clamp(0, 29); + let mut m = self.frames.lock().unwrap(); + Some(m.entry(i).or_insert_with(|| Arc::new(pattern(item.0 as u32 + i as u32, f.width, f.height))).clone()) + } +} + +#[test] +#[ignore] +fn bench_share() { + let Some(g) = gpu() else { return }; + let (w, h) = (1920, 1080); + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let f = Footage { + path: "seq.exr".into(), + kind: FootageKind::Sequence, + width: w, + height: h, + pixel_aspect: 1.0, + frame_rate: FrameRate::FPS_30, + duration: Tick::from_seconds_f64(1.0), + has_video: true, + alpha: AlphaMode::Premultiplied, + ..Default::default() + }; + let fid = p.add_item("Sequence", Label::Lavender, None, ItemKind::Footage(f)); + for i in 0..3 { + let mut l = build::layer(&mut p, &comp, "Sequence", LayerSource::Footage { item: fid }, (w, h), None); + if i > 0 { + l.blend_mode = BlendMode::Add; + set(&mut l, "transform/opacity", Value::Scalar(25.0)); + } + p.comp_mut(cid).unwrap().layers.insert(0, l); + } + let src = Decoded { frames: Mutex::new(HashMap::new()) }; + let frames = 30; + let mut runs = vec![]; + let mut hashes = vec![]; + for pass in 0..4 { + let cache = effectcraft_render::cache::LayerCache::new(1 << 30); + let mut r = Renderer::new(&p, &src, RenderOpts { backend: Backend::Gpu, ..Default::default() }); + r.accel = Some(g); + r.cache = Some(&cache); + for k in 0..frames { + let t0 = std::time::Instant::now(); + let img = g.render(&r, cid, Tick::from_seconds_f64(k as f64 / 30.0)).expect("the GPU renders the frame"); + let ms = t0.elapsed().as_secs_f64() * 1e3; + if pass > 0 { + runs.push(ms); + } + if pass == 1 { + use std::hash::{Hash, Hasher}; + let mut hs = std::collections::hash_map::DefaultHasher::new(); + img.data.iter().for_each(|px| px.map(f32::to_bits).hash(&mut hs)); + hashes.push(hs.finish()); + } + } + } + runs.sort_by(f64::total_cmp); + let mean = runs.iter().sum::() / runs.len() as f64; + eprintln!("bench_share: {} frames, median {:.1} ms, mean {:.1} ms, min {:.1} ms", runs.len(), runs[runs.len() / 2], mean, runs[0]); + eprintln!("bench_share frame hashes: {hashes:016x?}"); +} diff --git a/crates/gpu/src/tests/footage_share.rs b/crates/gpu/src/tests/footage_share.rs new file mode 100644 index 000000000..1a31c3981 --- /dev/null +++ b/crates/gpu/src/tests/footage_share.rs @@ -0,0 +1,233 @@ +//! #3333: a footage frame uploads once per frame and its texture is shared by every layer and +//! effect input that shows it; the frame is bit-identical to drawing each layer from its own copy. + +use std::collections::HashMap; +use std::sync::Mutex; + +use super::*; +use crate::walk::SHARED_PICTURES; + +/// Keeps decoded frames as the media pool does: the same frame (at the same size) of the same +/// footage is the same `Arc` while kept. Counts the frames it decodes. +#[derive(Default)] +struct Kept { + frames: Mutex), Arc>>, +} + +impl Kept { + fn get(&self, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { + let i = f.frame_rate.frame_at(t); + let mut m = self.frames.lock().unwrap(); + let img = m.entry((item, f.width, f.height, i, size)).or_insert_with(|| { + let full = pattern(item.0 as u32 + i as u32 * 7 + f.width, f.width, f.height); + Arc::new(match size { + Some((w, h)) => effectcraft_raster::resample(&full, w, h), + None => full, + }) + }); + Some(img.clone()) + } +} + +impl FootageSource for Kept { + fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option> { + self.get(item, f, t, None) + } + fn frame_at_size(&self, item: ItemId, f: &Footage, t: Tick, width: u32, height: u32) -> Option> { + self.get(item, f, t, Some((width, height))) + } +} + +/// One footage item (a sequence: a new picture every frame) shown by `layers` layers, each +/// placed differently (the first fills the comp). +fn shared_scene(depth: BitDepth, layers: usize) -> (Scene, ItemId) { + let mut s = Scene::new(depth); + let f = Footage { + path: "seq.exr".into(), + kind: FootageKind::Sequence, + width: 97, + height: 61, + pixel_aspect: 1.0, + frame_rate: FrameRate::FPS_30, + duration: Tick::from_seconds_f64(2.0), + has_video: true, + alpha: AlphaMode::Premultiplied, + ..Default::default() + }; + let fid = s.p.add_item("Sequence", Label::Lavender, None, ItemKind::Footage(f)); + for i in 0..layers { + let mut l = build::layer(&mut s.p, &s.comp, "Sequence", LayerSource::Footage { item: fid }, (97, 61), None); + if i > 0 { + set(&mut l, "transform/rotation", Value::Scalar(11.0 * i as f64)); + set(&mut l, "transform/scale", Value::Vec3([70.0 + 9.0 * i as f64, 64.0, 100.0])); + set(&mut l, "transform/position", v3(30.0 + 13.0 * i as f64, 25.0 + 4.0 * i as f64)); + set(&mut l, "transform/opacity", Value::Scalar(80.0)); + l.blend_mode = if i % 2 == 0 { BlendMode::Screen } else { BlendMode::Add }; + } + s.push(l); + } + (s, fid) +} + +/// The GPU frame with shared pictures on and off, and the number of uploads of each. +fn render_both(g: &crate::Gpu, s: &Scene, src: &Kept, opts: RenderOpts, t: Tick) -> (Image, Image, u64, u64) { + let mut r = Renderer::new(&s.p, src, RenderOpts { backend: Backend::Gpu, ..opts }); + r.accel = Some(g); + let before = g.ctx.transfer_stats().uploads; + let on = g.render(&r, s.cid, t).expect("the GPU renders the frame"); + let uploads = g.ctx.transfer_stats().uploads - before; + SHARED_PICTURES.store(false, std::sync::atomic::Ordering::Relaxed); + let before = g.ctx.transfer_stats().uploads; + let off = g.render(&r, s.cid, t); + let copied = g.ctx.transfer_stats().uploads - before; + SHARED_PICTURES.store(true, std::sync::atomic::Ordering::Relaxed); + (on, off.expect("the GPU renders the frame from per-layer copies"), uploads, copied) +} + +fn check_cpu(label: &str, s: &Scene, src: &Kept, opts: RenderOpts, t: Tick, gpu_img: &Image) { + let cpu = Renderer::new(&s.p, src, RenderOpts { backend: Backend::Cpu, ..opts }).comp_frame_cpu(s.cid, t); + let mut d = diff(&cpu, gpu_img, tolerance(s.p.settings.bit_depth)); + d.quantized = s.p.settings.bit_depth != BitDepth::Bpc32; + check(label, Some(d), 0.0); +} + +/// Three layers showing the same frame upload it once; a fourth that runs an effect on it +/// (resident on the GPU) reads the same texture as its input. Every frame of the sequence +/// uploads its own picture once. +#[test] +fn three_layers_of_one_footage_frame_upload_once() { + let Some(g) = gpu() else { return }; + let src = Kept::default(); + let (mut s, fid) = shared_scene(BitDepth::Bpc32, 3); + for k in 0..3 { + let t = Tick::from_seconds_f64(k as f64 / 30.0); + let (on, off, uploads, copied) = render_both(g, &s, &src, opts(), t); + assert_eq!(uploads, 1, "frame {k}: three layers of one footage frame uploaded {uploads} times"); + assert_eq!(copied, 3, "frame {k}: per-layer copies upload once per layer"); + assert!(on.data == off.data, "frame {k}: the shared picture changed the frame"); + check_cpu(&format!("shared picture frame {k}"), &s, &src, opts(), t, &on); + } + // An effect input: the blur reads the shared texture. Alone, the blurred layer uploads + // its picture (and whatever the blur itself uploads); the three plain layers showing the + // same frame add no upload. + let mut fx = build::layer(&mut s.p, &s.comp, "Blurred", LayerSource::Footage { item: fid }, (97, 61), None); + set(&mut fx, "transform/position", v3(20.0, 20.0)); + s.effect(&mut fx, "ec.blur.gaussian", &[("blurriness", n(3.0))]); + let (mut alone, _) = shared_scene(BitDepth::Bpc32, 0); + alone.p = s.p.clone(); + alone.p.comp_mut(alone.cid).unwrap().layers = vec![fx.clone()].into(); + let (_, _, blur_uploads, _) = render_both(g, &alone, &src, opts(), Tick::from_seconds_f64(0.4)); + s.push(fx); + let t = Tick::from_seconds_f64(0.5); + let (on, off, uploads, _) = render_both(g, &s, &src, opts(), t); + assert_eq!(uploads, blur_uploads, "three layers and an effect input of one footage frame uploaded {uploads} times, the effect layer alone {blur_uploads}"); + assert!(on.data == off.data, "the shared picture changed the frame with an effect input"); + check_cpu("shared picture with an effect input", &s, &src, opts(), t, &on); +} + +/// The frame is bit-identical with shared pictures on and off wherever they apply or not: every +/// bit depth, colour management and linear blending, half resolution, a track matte, Preserve +/// Transparency, motion blur, masks, layer styles and a proxy. +#[test] +fn shared_pictures_do_not_change_the_frame() { + let Some(g) = gpu() else { return }; + let src = Kept::default(); + let t = Tick::from_seconds_f64(0.2); + for depth in [BitDepth::Bpc8, BitDepth::Bpc16, BitDepth::Bpc32] { + let mut cases: Vec<(String, Scene, RenderOpts)> = vec![]; + cases.push(("plain".into(), shared_scene(depth, 3).0, opts())); + cases.push(("half resolution".into(), shared_scene(depth, 3).0, RenderOpts { scale: 0.5, ..opts() })); + for (ws, blend_linear) in [(Some(ColorSpace::Rec709), false), (None, true)] { + let (mut s, _) = shared_scene(depth, 3); + s.p.settings.working_space = ws; + s.p.settings.blend_linear = blend_linear; + cases.push((format!("colour {ws:?} linear blending {blend_linear}"), s, opts())); + } + { + let (mut s, _) = shared_scene(depth, 3); + let comp = s.p.comp_mut(s.cid).unwrap(); + comp.enable_motion_blur = true; + let matte = comp.layers[0].id; + comp.layers[1].track_matte = Some(TrackMatte { layer: matte, kind: MatteKind::Luma }); + comp.layers[2].preserve_transparency = true; + comp.layers[2].switches.motion_blur = true; + let pos = comp.layers[2].props.prop_mut("transform/position").unwrap(); + pos.keys = vec![Keyframe::new(Tick::ZERO, v3(10.0, 20.0)), Keyframe::new(Tick::from_seconds_f64(0.5), v3(90.0, 45.0))]; + s.comp.enable_motion_blur = true; + cases.push(("matte, preserve transparency, motion blur".into(), s, opts())); + } + { + let (mut s, _) = shared_scene(depth, 3); + let mut next = s.p.next_id; + let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::ellipse([40.0, 30.0], 60.0, 40.0), MaskMode::Add, [255, 255, 0]); + let comp = s.p.comp_mut(s.cid).unwrap(); + comp.layers[0].props.sub_mut("masks").unwrap().children.push(m.into()); + effectcraft_project::styles::add_style(&mut comp.layers[1], &mut Ids(&mut next), "dropShadow", &s.comp.global_light, true).unwrap(); + s.p.next_id = next; + cases.push(("mask and layer style".into(), s, opts())); + } + { + let (mut s, fid) = shared_scene(depth, 3); + let proxy = Footage { path: "proxy.png".into(), kind: FootageKind::Still, width: 48, height: 30, has_video: true, ..Default::default() }; + s.p.item_mut(fid).unwrap().proxy = Some(Box::new(effectcraft_project::Proxy { footage: proxy, enabled: true })); + cases.push(("proxy".into(), s, opts())); + } + for (label, s, o) in cases { + let (on, off, _, _) = render_both(g, &s, &src, o, t); + assert!(on.data == off.data, "{label} {depth:?}: the shared picture changed the frame"); + check_cpu(&format!("{label} {depth:?}"), &s, &src, o, t, &on); + } + } +} + +/// The picture cache keeps no texture of a picture the footage source dropped. +#[test] +fn picture_cache_drops_textures_of_dropped_pictures() { + let Some(g) = gpu() else { return }; + let before = g.ctx.picture_entries(); + for i in 0..6 { + let img = Arc::new(pattern(i, 64, 64)); + let up = g.ctx.upload_picture(&img).expect("upload"); + let again = g.ctx.upload_picture(&img).expect("upload"); + assert!(up.texture == again.texture, "the same picture uploaded twice"); + drop((up, again, img)); + } + let kept = g.ctx.picture_entries(); + assert!(kept <= before + 1, "the picture cache kept {kept} textures of dropped pictures (had {before})"); +} + +/// Large uploads copy through reused staging buffers (rows padded to the copy alignment) with +/// the same pixels as `Queue::write_texture`; the buffers stay within their budget, and a +/// clear while one is in flight forgets it. +#[test] +fn staging_uploads_keep_pixels_and_reuse_buffers() { + let Some(g) = gpu() else { return }; + let wait = || { + let _ = g.ctx.poll_readbacks(wgpu::PollType::wait_indefinitely()); + }; + // 300 px: 4800-byte rows, padded to 4864. + for (w, h) in [(300u32, 300u32), (512, 600)] { + let before = g.ctx.upload_staging().copies(); + for i in 0..4 { + let img = pattern(40 + i, w, h); + let up = g.ctx.upload_image(&img).expect("upload"); + let mut e = crate::context::Enc::new(&g.ctx); + let back = e.download(&up).expect("readback"); + assert!(back.data.iter().flatten().map(|v| v.to_bits()).eq(img.data.iter().flatten().map(|v| v.to_bits())), "{w}×{h} upload {i} changed pixels"); + drop((up, e)); + wait(); + } + assert!(g.ctx.upload_staging().copies() - before >= 2, "{w}×{h}: no upload went through a reused staging buffer"); + assert!(g.ctx.upload_staging().bytes() <= 192 << 20); + } + // A clear with a copy in flight: nothing kept, and later uploads still work. + let img = pattern(7, 300, 300); + let up = g.ctx.upload_image(&img).expect("upload"); + g.ctx.upload_staging().clear(); + wait(); + assert_eq!(g.ctx.upload_staging().bytes(), 0, "a cleared staging kept its buffers"); + let mut e = crate::context::Enc::new(&g.ctx); + assert!(e.download(&up).expect("readback").data == img.data); + let again = g.ctx.upload_image(&img).expect("upload after a clear"); + assert!(e.download(&again).expect("readback").data == img.data); +} diff --git a/crates/gpu/src/upload.rs b/crates/gpu/src/upload.rs new file mode 100644 index 000000000..9825fb335 --- /dev/null +++ b/crates/gpu/src/upload.rs @@ -0,0 +1,264 @@ +//! Footage uploads (#3333): each decoded footage picture uploads once and its texture is shared +//! by every layer and effect input that shows it ([`Pictures`]), and large uploads copy through +//! staging buffers that are kept mapped and reused ([`Staging`]) instead of one that +//! `Queue::write_texture` allocates and frees per upload. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, PoisonError, Weak}; + +use effectcraft_raster::Image; + +use crate::context::GpuImage; + +/// Bytes of picture textures kept after their frame (a sequence's next frame takes the oldest +/// one's texture back from the pool), at most a quarter of the context's upload budget. The +/// newest picture is always kept, whatever its size. +pub(crate) const PICTURE_BUDGET: usize = 256 << 20; + +/// Uploads from this size go through [`Staging`]; smaller ones through `Queue::write_texture`. +const STAGING_MIN: u64 = 1 << 20; + +/// Bytes of staging buffers that may exist at once (mapped and ready, or in flight). +const STAGING_BUDGET: u64 = 192 << 20; + +struct Picture { + img: Weak, + tex: GpuImage, + bytes: usize, + last_use: u64, +} + +/// Uploaded footage pictures by the identity of the decoded frame (its `Arc`): the footage +/// source serves one `Arc` per (file, frame, size, colour curve) while it keeps the frame, and +/// a new one when the file changed, so the same `Arc` means the same pixels. The `Weak` keeps +/// the allocation, so a key cannot be reused by another picture while its entry exists. +#[derive(Default)] +pub(crate) struct Pictures { + map: HashMap, + bytes: usize, + clock: u64, +} + +impl Pictures { + /// The texture of `img` if it was uploaded and is still kept. + pub(crate) fn get(&mut self, img: &Arc) -> Option { + self.clock += 1; + let now = self.clock; + let e = self.map.get_mut(&(Arc::as_ptr(img) as usize))?; + if !e.img.upgrade().is_some_and(|i| Arc::ptr_eq(&i, img)) { + return None; + } + e.last_use = now; + Some(e.tex.clone()) + } + + /// Keep `tex`, the upload of `img`. Pictures the footage source no longer holds go at once + /// (nothing can ask for them again), then the least recently used over [`PICTURE_BUDGET`], + /// never `img` itself. A dropped texture goes back to the pool once no open encoder can + /// still read it. + pub(crate) fn insert(&mut self, img: &Arc, tex: GpuImage, budget: usize) { + let key = Arc::as_ptr(img) as usize; + let bytes = img.data.len().saturating_mul(16); + self.clock += 1; + let entry = Picture { img: Arc::downgrade(img), tex, bytes, last_use: self.clock }; + if let Some(old) = self.map.insert(key, entry) { + self.bytes = self.bytes.saturating_sub(old.bytes); + } + self.bytes = self.bytes.saturating_add(bytes); + let dead: Vec = self.map.iter().filter(|(_, e)| e.img.strong_count() == 0).map(|(k, _)| *k).collect(); + for k in dead { + if let Some(e) = self.map.remove(&k) { + self.bytes = self.bytes.saturating_sub(e.bytes); + } + } + while self.bytes > budget { + let Some(k) = self.map.iter().filter(|(k, _)| **k != key).min_by_key(|(_, e)| e.last_use).map(|(k, _)| *k) else { break }; + if let Some(e) = self.map.remove(&k) { + self.bytes = self.bytes.saturating_sub(e.bytes); + } + } + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.map.len() + } + + pub(crate) fn clear(&mut self) { + self.map.clear(); + self.bytes = 0; + } +} + +#[derive(Default)] +struct StagingState { + /// Buffers mapped for writing and not in use, each mapped whole. + ready: Vec, + /// Bytes of every staging buffer that exists (ready, being mapped or in flight). + total: u64, + /// Raised by [`Staging::clear`]: a buffer of an earlier generation is dropped when its + /// mapping completes. + generation: u64, + /// Uploads copied through a staging buffer (diagnostics, tests). + copies: u64, +} + +/// Reused upload staging buffers (`MAP_WRITE | COPY_SRC`). A buffer is written while mapped, +/// unmapped, copied into the texture by a command buffer submitted at once, and mapped again +/// when that submission is done (`CommandEncoder::map_buffer_on_submit`; the callback runs when +/// the device is polled, as every frame does). A new buffer is mapped asynchronously too (never +/// `mapped_at_creation`, which fails hard on drivers that report a failed allocation as an +/// internal error, #591), so the upload that made it, and any upload while no ready buffer fits +/// or the budget is spent, goes through `Queue::write_texture` instead. +#[derive(Default)] +pub(crate) struct Staging { + state: Arc>, +} + +/// `bytes_per_row` of a staging copy: rows padded to `COPY_BYTES_PER_ROW_ALIGNMENT`. +fn padded_row(width: u32) -> Option { + let row = u64::from(width).checked_mul(16)?; + let a = u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); + row.div_ceil(a).checked_mul(a) +} + +impl Staging { + fn lock(&self) -> std::sync::MutexGuard<'_, StagingState> { + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Bytes of staging buffers that exist (see [`STAGING_BUDGET`]). + pub(crate) fn bytes(&self) -> u64 { + self.lock().total + } + + /// Uploads copied through a staging buffer so far. + #[cfg(test)] + pub(crate) fn copies(&self) -> u64 { + self.lock().copies + } + + /// Forget every staging buffer (after a failed frame, #1319): those in flight are dropped + /// when their mapping completes. + pub(crate) fn clear(&self) { + let mut s = self.lock(); + s.ready.clear(); + // (buffers still in flight are no longer counted: they drop when their mapping ends) + s.total = 0; + s.generation = s.generation.wrapping_add(1); + } + + /// The callback of a write mapping of `buffer`: once mapped it is ready for the next + /// upload; it is dropped (and its bytes uncounted) when mapping failed, and dropped when + /// the staging was cleared meanwhile (whose [`Staging::clear`] stopped counting it). + fn on_mapped(&self, buffer: &wgpu::Buffer, generation: u64) -> impl FnOnce(Result<(), wgpu::BufferAsyncError>) + wgpu::WasmNotSend + 'static { + let state = Arc::downgrade(&self.state); + let buffer = buffer.clone(); + move |res: Result<(), wgpu::BufferAsyncError>| { + let Some(state) = state.upgrade() else { return }; + let mut s = state.lock().unwrap_or_else(PoisonError::into_inner); + if s.generation != generation { + return; + } + if res.is_ok() { + s.ready.push(buffer); + } else { + s.total = s.total.saturating_sub(buffer.size()); + } + } + } + + /// Copy `img` into `texture` (its size) through a ready staging buffer, recorded and + /// submitted at once: before anything an open encoder records later, as + /// `Queue::write_texture` would. `false`: not done (small, no ready buffer fits, or the + /// mapping failed); the caller writes it through the queue. Starts mapping a new buffer for + /// later uploads when none fits and the budget allows. + pub(crate) fn write(&self, device: &wgpu::Device, queue: &wgpu::Queue, img: &Image, texture: &wgpu::Texture) -> bool { + let (w, h) = (img.width, img.height); + let row = w as usize * 16; + let Some(stride) = padded_row(w) else { return false }; + let (Some(need), Ok(stride32)) = (stride.checked_mul(u64::from(h)), u32::try_from(stride)) else { return false }; + if need < STAGING_MIN || img.data.len() != w as usize * h as usize { + return false; + } + let (buffer, generation) = { + let mut s = self.lock(); + let generation = s.generation; + // The smallest ready buffer that holds it. + let best = s.ready.iter().enumerate().filter(|(_, b)| b.size() >= need).min_by_key(|(_, b)| b.size()).map(|(i, _)| i); + match best { + Some(i) => (Some(s.ready.swap_remove(i)), generation), + None => { + // Ready buffers too small for it make room (a size that no longer occurs). + while s.total.saturating_add(need) > STAGING_BUDGET + && let Some(b) = s.ready.pop() + { + s.total = s.total.saturating_sub(b.size()); + } + if s.total.saturating_add(need) <= STAGING_BUDGET { + s.total += need; + drop(s); + let b = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("upload staging"), + size: need, + usage: wgpu::BufferUsages::MAP_WRITE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + let done = self.on_mapped(&b, generation); + b.map_async(wgpu::MapMode::Write, .., done); + } + (None, generation) + } + } + }; + let Some(buffer) = buffer else { return false }; + self.lock().copies += 1; + { + let Ok(mut view) = buffer.get_mapped_range_mut(..need) else { + // Not mapped after all: give it up (its bytes too). + let mut s = self.lock(); + if s.generation == generation { + s.total = s.total.saturating_sub(buffer.size()); + } + return false; + }; + let src: &[u8] = bytemuck::cast_slice(&img.data); + let stride = stride as usize; + if stride == row { + view.slice(..src.len()).copy_from_slice(src); + } else { + for (y, line) in src.chunks_exact(row).enumerate() { + let at = y * stride; + view.slice(at..at + row).copy_from_slice(line); + } + } + } + buffer.unmap(); + let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("upload") }); + enc.copy_buffer_to_texture( + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(stride32), rows_per_image: Some(h) }, + }, + wgpu::TexelCopyTextureInfo { texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + let done = self.on_mapped(&buffer, generation); + enc.map_buffer_on_submit(&buffer, wgpu::MapMode::Write, .., done); + queue.submit([enc.finish()]); + true + } +} + +#[cfg(test)] +mod tests { + use super::padded_row; + + #[test] + fn staging_rows_are_padded_to_the_copy_alignment() { + assert_eq!(padded_row(1920), Some(30720)); + assert_eq!(padded_row(97), Some(1792)); + assert_eq!(padded_row(16), Some(256)); + assert_eq!(padded_row(1), Some(256)); + } +} diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index f688a6375..471c91339 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -241,6 +241,9 @@ fn draw_layer_on(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas if let Some(done) = draw_resident_fx(e, r, ctx, layer, canvas, opacity) { return done; } + if let Some(done) = draw_picture(e, r, ctx, layer, canvas, opacity) { + return done; + } let Some(st) = r.styled_layer(ctx, layer) else { return Some(()) }; if st.plain { return composite_layer(e, r, ctx, layer, &st.body, canvas, opacity); @@ -286,14 +289,28 @@ fn draw_resident_fx(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, can { return None; } - let input = r.layer_input(ctx, layer, 0)?; - if input.img.is_empty() { - return Some(Some(())); - } - let Some(src) = e.g.upload_buf(&input) else { return Some(None) }; + // The decoded footage picture itself when it is the layer's input (uploaded once per frame + // and shared with every layer that shows it), else the cached input buffer. + let (src, offset, scale) = match picture(r, ctx, layer) { + Some(p) => { + if p.img.is_empty() { + return Some(Some(())); + } + let Some(src) = e.g.upload_picture(&p.img) else { return Some(None) }; + (src, p.effect_offset, p.scale) + } + None => { + let input = r.layer_input(ctx, layer, 0)?; + if input.img.is_empty() { + return Some(Some(())); + } + let Some(src) = e.g.upload_buf(&input) else { return Some(None) }; + (src, input.offset, input.scale) + } + }; let pipe = r.pipe(); // `layer_input` is in effect space: run the stack there, then back to layer space. - let mut fx = crate::effects::GpuFx::new(e, src, input.offset, input.scale, pipe.levels); + let mut fx = crate::effects::GpuFx::new(e, src, offset, scale, pipe.levels); let origin = r.run_layer_effects_on(ctx, layer, &mut fx); let Some((mut img, mut offset, scale)) = fx.finish() else { return Some(None) }; offset = [offset[0] - origin[0] * scale, offset[1] - origin[1] * scale]; @@ -308,6 +325,52 @@ fn draw_resident_fx(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, can Some(place_src(e, r, ctx, layer, &shape, img, canvas, layer.blend_mode, opacity, pipe.levels).map(|c| *canvas = c)) } +/// Shared footage pictures ([`picture`]); tests turn it off to compare. +pub(crate) static SHARED_PICTURES: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); + +/// The layer's source as the decoded footage picture (`Renderer::footage_picture`), unless +/// sharing is off or per-layer timings are being recorded (those time the layer-cache path). +fn picture(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> Option { + if !SHARED_PICTURES.load(std::sync::atomic::Ordering::Relaxed) || r.profile.is_some() { + return None; + } + r.footage_picture(ctx, layer) +} + +/// A footage layer without effects or layer styles whose source is the decoded picture as it +/// is (`Renderer::footage_picture`) and needs no conversion to the blending space: placed +/// straight from the picture's texture, uploaded once per frame and shared by every layer that +/// shows the same frame, instead of from a copy per layer in the layer cache, uploaded per +/// layer. The pixels are the same: the layer-cache buffer is that picture, copied. `None`: not +/// this kind of layer; `Some(None)`: the GPU failed. +fn draw_picture(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage, opacity: f32) -> Option> { + if (layer.switches.effects && layer.effects().is_some_and(|f| !f.children.is_empty())) || styles::active(ctx, layer) || r.pipe().to_blend().is_some() { + return None; + } + let p = picture(r, ctx, layer)?; + if p.img.is_empty() { + return Some(Some(())); + } + let Some(src) = e.g.upload_picture(&p.img) else { return Some(None) }; + // The placement only reads the buffer's size, offset and scale. + let shape = Buf { img: Image { width: p.img.width, height: p.img.height, data: Vec::new() }, offset: [0.0; 2], scale: p.scale }; + Some(composite_src(e, r, ctx, layer, &shape, src, canvas, opacity)) +} + +/// [`composite_layer`] of a GPU image `src` shaped like `shape`. +#[allow(clippy::too_many_arguments)] +fn composite_src(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, shape: &Buf, src: GpuImage, canvas: &mut GpuImage, opacity: f32) -> Option<()> { + if opacity <= 0.0 { + return Some(()); + } + if r.track_matte(ctx, layer).is_none() && !layer.preserve_transparency { + *canvas = place_src(e, r, ctx, layer, shape, src, canvas, layer.blend_mode, opacity, r.pipe().levels)?; + return Some(()); + } + let iso = place_src(e, r, ctx, layer, shape, src, &e.zeros(canvas.width, canvas.height), BlendMode::Normal, 1.0, None)?; + composite_iso(e, r, ctx, layer, iso, canvas, opacity) +} + /// A 2D precomp layer, its nested comp rendered resident on the GPU (`walk::render` one level /// down) instead of by the CPU compositor and uploaded every frame (`Renderer::layer_source` /// renders nested comps on the CPU: depth > 0). Then the layer's effects (resident), its diff --git a/crates/gpu/src/yuv.rs b/crates/gpu/src/yuv.rs index 27df90110..978ee4d5d 100644 --- a/crates/gpu/src/yuv.rs +++ b/crates/gpu/src/yuv.rs @@ -147,7 +147,7 @@ pub(crate) fn record(e: &mut Enc, yp: &YuvPipe, img: &GpuImage, bg: [f32; 3]) -> pub(crate) fn convert(e: &mut Enc, yp: &YuvPipe, img: &GpuImage, bg: [f32; 3]) -> Option> { let stg = record(e, yp, img, bg)?; let n = planes_len(img.width, img.height) as usize; - e.g.read_buffer(&stg, n, n, 1).map_err(|err| log::error!(target: "effectcraft_gpu", "yuv420 readback: {err}")).ok() + e.g.read_buffer_after(&stg, e.last_submission(), n, n, 1).map_err(|err| log::error!(target: "effectcraft_gpu", "yuv420 readback: {err}")).ok() } #[cfg(test)] diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 74fa8348d..3d1384a90 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -62,6 +62,9 @@ impl Exporter for FileExporter { label: job.label.clone(), storage: job.storage, 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 1eb8b546f..d1ab70ab9 100644 --- a/crates/render/src/accel.rs +++ b/crates/render/src/accel.rs @@ -34,6 +34,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, @@ -94,6 +110,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/cache.rs b/crates/render/src/cache.rs index 0756c47f2..7bc52606f 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -230,6 +230,11 @@ impl LayerCache { /// used. (A render that started before a drop may publish its buffer after the drop's /// purge, Reload Footage clearing the cache while the buffer was being written: the check /// here, not the order of the two, keeps it from being served.) + /// Whether a buffer is kept in memory under `key` (a peek: no hit, no miss, no disk read). + pub(crate) fn holds(&self, key: u64) -> bool { + self.kept(key, false).is_some() + } + fn kept(&self, key: u64, hit: bool) -> Option<(Arc, Arc)> { let found = { let mut g = self.inner.lock().ok()?; diff --git a/crates/render/src/layers.rs b/crates/render/src/layers.rs index df976a857..cfae5dbab 100644 --- a/crates/render/src/layers.rs +++ b/crates/render/src/layers.rs @@ -295,7 +295,15 @@ impl<'a> Renderer<'a> { /// in this render ([`Self::mask_blur`]'s gate: the render options, the precomp layers above). /// The key has the layer's own motion blur state, but a mask set to On blurs without it. pub(crate) fn mask_gate_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option) -> Option { + // Queue jobs share this cache but may override Proxy Use differently. The item's + // proxy switch alone does not describe the source a renderer actually reads. let k = key?; + let k = match self.opts.proxy { + effectcraft_project::render_queue::ProxyUse::CurrentSettings => k, + effectcraft_project::render_queue::ProxyUse::UseAll => cache::derive(k, 0x7072_6f78_7901), + effectcraft_project::render_queue::ProxyUse::UseCompOnly => cache::derive(k, 0x7072_6f78_7902), + effectcraft_project::render_queue::ProxyUse::UseNone => cache::derive(k, 0x7072_6f78_7903), + }; let masked = layer.masks().is_some_and(|m| !m.children.is_empty()); Some(if masked && !self.mask_blur(ctx, layer).allowed { cache::derive(k, 0x6d62_6f66) } else { k }) } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 65f3ee772..05b443763 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, vector_fit}; pub use frames::{ adaptive_samples, blend_frames, cineon_frame, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path, @@ -61,7 +61,7 @@ pub use layers::{Placement, StyledLayer}; pub(crate) use opts::Collapse3d; pub use opts::{LayerTiming, RenderOpts}; use opts::{MAX_THROUGH, Through}; -pub use pipeline::{Collapsed, MediaOverride, PropertyOverride}; +pub use pipeline::{Collapsed, FootagePicture, MediaOverride, PropertyOverride}; pub struct Renderer<'a> { pub project: &'a Project, diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 6c79ba7bb..1d9efc795 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -19,6 +19,24 @@ pub(crate) struct EffectStack { pub origin: [f64; 2], } +/// A footage layer's source as the decoded picture itself (see [`Renderer::footage_picture`]): +/// its buffer in layer space is `img` at offset `[0, 0]` and `scale`; in effect space (what +/// [`Renderer::layer_input`] gives) at `effect_offset`. +#[derive(Clone, Debug)] +pub struct FootagePicture { + pub img: Arc, + pub scale: f64, + pub effect_offset: [f64; 2], +} + +/// How a decoded footage frame becomes a layer buffer ([`Renderer::footage_buf`]). +enum FootageLayout { + /// Its own pixels, at this buffer scale. + Native { scale: f64 }, + /// Resampled to `width`×`height` at this buffer scale. + Resample { width: u32, height: u32, scale: f64 }, +} + /// A property value an Essential Properties override gives a layer of a collapsed precomp. #[derive(Clone, Debug)] pub struct PropertyOverride { @@ -861,12 +879,7 @@ impl<'a> Renderer<'a> { return Some(buf); } } - let lt = ctx.source_time(layer); - // The proxy is decoded instead, at its own size, and fills the footage's frame. - let proxy = self.proxy_for(item); - let pf = proxy.unwrap_or(f); - let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none()); - let img = self.footage_frame(ctx, layer, item, pf, lt, size)?; + let (img, pf) = self.footage_picture_of(ctx, layer, item, f)?; Some(self.footage_buf(&img, pf, f.width, f.height)) } // (the font files go to this render's log, which `ctx` may not carry: see @@ -898,14 +911,9 @@ impl<'a> Renderer<'a> { /// A decoded frame of `f` as a layer buffer covering `w`×`h` layer pixels (the footage's /// nominal size; proxies are scaled up to it), converted from its colour profile. fn footage_buf(&self, img: &Image, f: &Footage, w: u32, h: u32) -> Buf { - let s = self.opts.scale; - let k = if img.width > 0 { w.max(1) as f64 / img.width as f64 } else { 1.0 }; - // Keep native pixels when downsampling is small; resample otherwise. - let mut buf = match self.reduced_size(w, h) { - // The source resampled it already (`FootageSource::frame_at_size`). - Some(size) if (img.width, img.height) == size => Buf { img: img.clone(), offset: [0.0; 2], scale: s }, - Some((rw, rh)) if k <= 1.0 + 1e-9 => Buf { img: effectcraft_raster::resample(img, rw, rh), offset: [0.0; 2], scale: s }, - _ => Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 / k }, + let mut buf = match self.footage_layout(img, w, h) { + FootageLayout::Native { scale } => Buf { img: img.clone(), offset: [0.0; 2], scale }, + FootageLayout::Resample { width, height, scale } => Buf { img: effectcraft_raster::resample(img, width, height), offset: [0.0; 2], scale }, }; if let Some(c) = self.pipe.media_in(f) { color::convert(&mut buf.img, &c); @@ -913,6 +921,79 @@ impl<'a> Renderer<'a> { buf } + /// How [`Renderer::footage_buf`] lays out a decoded frame `img` of footage `w`×`h` layer + /// pixels: its own pixels, or resampled. + fn footage_layout(&self, img: &Image, w: u32, h: u32) -> FootageLayout { + let s = self.opts.scale; + let k = if img.width > 0 { w.max(1) as f64 / img.width as f64 } else { 1.0 }; + // Keep native pixels when downsampling is small; resample otherwise. + match self.reduced_size(w, h) { + // The source resampled it already (`FootageSource::frame_at_size`). + Some(size) if (img.width, img.height) == size => FootageLayout::Native { scale: s }, + Some((width, height)) if k <= 1.0 + 1e-9 => FootageLayout::Resample { width, height, scale: s }, + _ => FootageLayout::Native { scale: 1.0 / k }, + } + } + + /// A footage layer's decoded frame at the context time (the proxy's when one is used) and + /// the footage it was decoded from: what [`Renderer::source`] makes the layer's buffer of. + fn footage_picture_of(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &'a Footage) -> Option<(Arc, &'a Footage)> { + let lt = ctx.source_time(layer); + // The proxy is decoded instead, at its own size, and fills the footage's frame. + let proxy = self.proxy_for(item); + let pf = proxy.unwrap_or(f); + let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none()); + let img = self.footage_frame(ctx, layer, item, pf, lt, size)?; + Some((img, pf)) + } + + /// The decoded footage picture a layer's masked source is, bit for bit, with no copy: a + /// footage layer (not continuously rasterised vector footage) without masks whose frame + /// needs no colour conversion, no resampling and no quantisation (32 bpc). The buffer + /// [`Renderer::layer_input`] gives for no effects is this picture at `effect_offset` and + /// `scale`. + /// Layers showing the same frame get the same `Arc` (the footage source's cached frame), so + /// an accelerator uploads it once and shares the texture. `None`: the layer's buffer is + /// made the usual way (any of the above applies, or the frame is missing), and also when the + /// frame is made per call rather than served by the footage source (frame blending, + /// separate fields, Invert Alpha, Interpret As Linear Light, Cineon conversion: a new + /// picture each time, nothing to share) or when the layer cache already holds the layer's + /// buffer (served from memory as before, without asking the footage source). + pub fn footage_picture(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + let LayerSource::Footage { item } = &layer.source else { return None }; + if self.pipe.levels.is_some() || layer.masks().is_some_and(|m| !m.children.is_empty()) || frame_blend_mode(ctx, layer) != FrameBlend::Off { + return None; + } + let item = ctx.media_item(layer, *item); + let ItemKind::Footage(f) = &self.project.item(item)?.kind else { return None }; + if !f.has_video || (layer.switches.collapse && is_vector_footage(f)) { + return None; + } + let pf = self.proxy_for(item).unwrap_or(f); + if self.pipe.media_in(pf).is_some() + || pf.fields != effectcraft_project::FieldOrder::Off + || pf.invert_alpha + || pf.linear_light_applies() + || pf.cineon_applies() + { + return None; + } + if let Some(cache) = self.cache { + let (scale, draft, blur) = (self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer)); + let content = self.content_key(ctx, layer, cache::layer_key(ctx, layer, scale, draft, blur)); + let input = self.content_key(ctx, layer, cache::input_key(ctx, layer, scale, draft, blur, 0)); + if [content, input].into_iter().flatten().any(|k| cache.holds(k)) { + return None; + } + } + let (img, _) = self.footage_picture_of(ctx, layer, item, f)?; + let FootageLayout::Native { scale } = self.footage_layout(&img, f.width, f.height) else { return None }; + // `layer_input`'s rebase into effect space (`Buf::rebase` of a buffer at the origin). + let mut at = Buf { img: Image { width: 0, height: 0, data: Vec::new() }, offset: [0.0; 2], scale }; + at.rebase(ctx.effect_bounds(layer).1); + Some(FootagePicture { img, scale, effect_offset: at.offset }) + } + /// The size of `w` × `h` layer pixels in this render when it is reduced enough that footage /// is resampled to it (below 3/4 scale). fn reduced_size(&self, w: u32, h: u32) -> Option<(u32, u32)> { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index e7dd02182..53e2a512c 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -21,6 +21,7 @@ mod mask_feather; mod motion_tile; mod nested; mod paint; +mod proxy_cache; mod remap; mod roi; mod stretch_limits; diff --git a/crates/render/src/tests/proxy_cache.rs b/crates/render/src/tests/proxy_cache.rs new file mode 100644 index 000000000..85343a25d --- /dev/null +++ b/crates/render/src/tests/proxy_cache.rs @@ -0,0 +1,45 @@ +use super::setup; +use crate::eval::EvalCtx; +use crate::{FootageSource, Image, LayerCache, RenderOpts, Renderer}; +use effectcraft_color::Label; +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerSource, Proxy, build, render_queue::ProxyUse}; +use effectcraft_time::Tick; +use std::sync::Arc; + +/// A source and its proxy deliberately have different pixels. +struct Pictures; +impl FootageSource for Pictures { + fn frame(&self, _: ItemId, footage: &Footage, _: Tick) -> Option> { + let color = if footage.path == "proxy" { [0.0, 1.0, 0.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] }; + Some(Arc::new(Image::filled(16, 16, color))) + } +} + +/// A queue run reuses its layer cache across jobs with different Proxy Use settings. +/// Time effects read layer_input, which must never serve another job's source pixels. +#[test] +fn shared_layer_cache_separates_proxy_use() { + let (mut p, cid, comp) = setup(); + let footage = Footage { path: "original".into(), kind: FootageKind::Still, width: 16, height: 16, has_video: true, ..Default::default() }; + let item = p.add_item("Footage", Label::Red, None, ItemKind::Footage(footage.clone())); + p.item_mut(item).unwrap().proxy = Some(Box::new(Proxy { footage: Footage { path: "proxy".into(), ..footage }, enabled: false })); + let layer = build::layer(&mut p, &comp, "Footage", LayerSource::Footage { item }, (16, 16), None); + p.comp_mut(cid).unwrap().layers.push(layer); + for enabled in [false, true] { + p.item_mut(item).unwrap().proxy.as_mut().unwrap().enabled = enabled; + let cache = LayerCache::default(); + let ctx = EvalCtx::new(&p, cid, p.comp(cid).unwrap(), Tick::ZERO); + let layer = &ctx.comp.layers[0]; + for proxy in [ProxyUse::UseAll, ProxyUse::UseNone, ProxyUse::CurrentSettings, ProxyUse::UseCompOnly, ProxyUse::UseAll] { + let mut r = Renderer::new(&p, &Pictures, RenderOpts { proxy, ..Default::default() }); + let input = r.layer_input(&ctx, layer, 0).unwrap(); + let content = r.content_buf(&ctx, layer).unwrap(); + r.cache = Some(&cache); + assert_eq!(r.layer_input(&ctx, layer, 0).unwrap().img.data, input.img.data, "{proxy:?}, enabled={enabled}"); + assert_eq!(r.content_buf(&ctx, layer).unwrap().img.data, content.img.data, "{proxy:?}, enabled={enabled}"); + let hits = cache.stats().hits; + r.layer_input(&ctx, layer, 0).unwrap(); + assert!(cache.stats().hits > hits, "unchanged proxy settings still reuse inputs"); + } + } +} diff --git a/crates/ui-egui/examples/rq_bench.rs b/crates/ui-egui/examples/rq_bench.rs new file mode 100644 index 000000000..6d470108b --- /dev/null +++ b/crates/ui-egui/examples/rq_bench.rs @@ -0,0 +1,149 @@ +//! Render Queue benchmark (#3335): a 1080p 32 bpc job on the GPU, the CPU or the measured choice. +//! +//! `cargo run --release -p effectcraft-ui-egui --example rq_bench -- [options]` +//! +//! It builds a comp like a compositing shot: a 1920 × 1080 OpenEXR sequence (float RGBA with a +//! Cryptomatte ID channel and manifest, generated once into `target/rq-bench/media`, no +//! committed media) under Gaussian Blur, Curves and the Cryptomatte effect and a moving +//! translucent solid, at 32 bpc. It queues the comp twice (the second item shows a warm start) and +//! renders the queue as the app does, printing each item's milliseconds per frame and a hash of +//! the written pixels (equal hashes: identical output). +//! +//! Options: `--renderer gpu|cpu` (the project's renderer, default gpu; with gpu the Render +//! Queue measures both unless `EFFECTCRAFT_RQ_RENDERER` says otherwise), `--frames N` (default +//! 60), `--format png|exr|tiff` (default png), `--out DIR` (default `target/rq-bench/out`). + +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_engine::raster::channels3d::{crypto_float, crypto_hash}; +use serde_json::{Value, json}; + +const W: usize = 1920; +const H: usize = 1080; + +fn arg(name: &str) -> Option { + let a: Vec = std::env::args().collect(); + a.iter().position(|x| x == name).and_then(|i| a.get(i + 1).cloned()) +} + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +/// The plate's object at pixel (x, y) in frame `f`: 24 bands that drift sideways. +fn object(x: usize, y: usize, f: usize) -> String { + format!("obj{}", ((x + f * 7) / 80 + y / 270 * 24) % 96) +} + +/// Frame `f` of the plate: float colour over 1.0 (32 bpc), alpha, and Cryptomatte IDs. +fn plate(path: &std::path::Path, f: usize) { + use exr::prelude::*; + let px = |c: usize| -> Vec { + (0..W * H) + .map(|i| { + let (x, y) = (i % W, i / W); + let v = ((x as f32 * 0.003 + f as f32 * 0.05 + c as f32).sin() * 0.5 + 0.5) * (1.0 + y as f32 / H as f32); + if c == 3 { 1.0 } else { v } + }) + .collect() + }; + let ids: Vec = (0..W * H).map(|i| crypto_float(crypto_hash(&object(i % W, i / W, f)))).collect(); + let ch = |n: &str, v: Vec| AnyChannel::new(n, FlatSamples::F32(v)); + let channels = AnyChannels::sort( + vec![ch("R", px(0)), ch("G", px(1)), ch("B", px(2)), ch("A", px(3)), ch("CryptoObject00.R", ids), ch("CryptoObject00.G", vec![1.0; W * H])].into(), + ); + let mut layer = Layer::new((W, H), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels); + let names: serde_json::Map = (0..96).map(|k| format!("obj{k}")).map(|n| (n.clone(), json!(format!("{:08x}", crypto_hash(&n))))).collect(); + for (key, v) in [("name", "CryptoObject".to_string()), ("manifest", Value::Object(names).to_string())] { + layer.attributes.other.insert(Text::from(format!("cryptomatte/f834d0a/{key}").as_str()), AttributeValue::Text(Text::from(v.as_str()))); + } + Image::from_layer(layer).write().to_file(path).expect("write plate"); +} + +/// FNV-1a over every written frame file, in name order (equal: identical output). +fn hash_dir(dir: &std::path::Path) -> (usize, u64) { + let mut names: Vec<_> = std::fs::read_dir(dir).map(|d| d.filter_map(|e| e.ok()).map(|e| e.path()).collect()).unwrap_or_default(); + names.retain(|p| !p.to_string_lossy().ends_with(".txt")); + names.sort(); + let mut h = 0xcbf2_9ce4_8422_2325u64; + for p in &names { + for b in std::fs::read(p).unwrap_or_default() { + h = (h ^ b as u64).wrapping_mul(0x100_0000_01b3); + } + } + (names.len(), h) +} + +fn main() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/rq-bench"); + let frames: usize = arg("--frames").and_then(|n| n.parse().ok()).unwrap_or(60); + let format = arg("--format").unwrap_or_else(|| "png".into()); + let gpu = arg("--renderer").as_deref() != Some("cpu"); + let out = arg("--out").map(std::path::PathBuf::from).unwrap_or_else(|| root.join("out")); + let media = root.join("media"); + std::fs::create_dir_all(&media).expect("media folder"); + let first = media.join("plate_0000.exr"); + for f in 0..frames { + let p = media.join(format!("plate_{f:04}.exr")); + if !p.exists() { + if f == 0 { + eprintln!("writing the {frames}-frame EXR plate (once)…"); + } + plate(&p, f); + } + } + + let mut s = effectcraft_host::session(); + let accel = effectcraft_gpu::Gpu::try_headless().unwrap_or_else(|e| panic!("no GPU: {e}")); + eprintln!("GPU: {}", effectcraft_engine::render::Accelerator::name(&accel)); + s.accel = Some(Arc::new(accel)); + let r = run(&mut s, "file.import", json!({"paths": [first.to_string_lossy()], "sequence": true})); + let footage = r["items"][0].as_u64().expect("the plate"); + let secs = frames as f64 / 30.0; + run(&mut s, "comp.new", json!({"name": "Shot", "width": W, "height": H, "frameRate": 30, "duration": secs})); + let plate = run(&mut s, "layer.addItem", json!({"item": footage}))["layer"].clone(); + run(&mut s, "effect.apply", json!({"layers": [plate], "effect": "Gaussian Blur"})); + run(&mut s, "prop.set", json!({"layer": plate, "path": "effects/#1/blurriness", "value": 12.0})); + run(&mut s, "effect.apply", json!({"layers": [plate], "effect": "Curves"})); + run(&mut s, "effect.apply", json!({"layers": [plate], "effect": "Cryptomatte"})); + let solid = run(&mut s, "layer.newSolid", json!({"color": "#d04020", "width": 400, "height": 300}))["layer"].clone(); + run(&mut s, "prop.addKey", json!({"layer": solid, "path": "transform/position", "time": 0.0, "value": [200.0, 540.0]})); + run(&mut s, "prop.addKey", json!({"layer": solid, "path": "transform/position", "time": secs, "value": [1720.0, 540.0]})); + run(&mut s, "prop.set", json!({"layer": solid, "path": "transform/opacity", "value": 70.0})); + let mut p = (*s.project).clone(); + p.settings.bit_depth = effectcraft_engine::project::BitDepth::Bpc32; + p.settings.gpu_acceleration = gpu; + s.project = Arc::new(p); + + let _ = std::fs::remove_dir_all(&out); + let dirs = [out.join("a"), out.join("b")]; + 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(), "log": "plusSettings"})); + } + let t0 = std::time::Instant::now(); + run(&mut s, "renderQueue.render", json!({"wait": true})); + let total = t0.elapsed().as_secs_f64(); + println!( + "renderer {} (EFFECTCRAFT_RQ_RENDERER={:?}, EFFECTCRAFT_GPU_INFLIGHT={:?}), {frames} frames {W}x{H} 32 bpc, {format}", + if gpu { "gpu" } else { "cpu" }, + std::env::var("EFFECTCRAFT_RQ_RENDERER").ok(), + std::env::var("EFFECTCRAFT_GPU_INFLIGHT").ok() + ); + for (i, (item, d)) in s.project.render_queue.iter().zip(&dirs).enumerate() { + 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 ac6504493..259fe2d60 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", "ビデオデバイス"), @@ -2023,6 +2024,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", "视频设备"), @@ -3135,6 +3137,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"), @@ -4352,6 +4355,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", "視訊裝置"), @@ -5917,6 +5921,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