From 29619dfe6749cd977f005a6b73e4ef7bcf12d228 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:38:57 +0100 Subject: [PATCH 1/2] Preview: render at most two frames on the GPU at once so video memory stays flat (#3028) --- crates/ui-egui/src/frames.rs | 59 ++++++++++++++++++++++++++++-- crates/ui-egui/src/frames/tests.rs | 22 +++++++++++ docs/architecture.md | 4 +- 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 9aad2a81c..c4cc1eee1 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -26,7 +26,7 @@ use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Condvar, Mutex, PoisonError}; use effectcraft_engine::project::{Comp, ItemId, ItemKind, LayerSource, Node, Project, PropGroup}; use effectcraft_engine::render::disk_cache::{self, DiskCache}; @@ -47,6 +47,46 @@ const MIN_GPU_BUDGET: usize = 64 << 20; const GPU_PRESSURE_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(5); const GPU_RECOVERY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); +/// GPU frames that render at once, however many frame threads there are. Each one holds its own +/// working textures, so 16 at once ran an RTX 3090 out of video memory (#3028). +const MAX_GPU_RENDERS: usize = 2; + +/// Counts the GPU renders in flight; the rest of the frame threads wait their turn. +struct GpuSlots { + busy: Mutex, + freed: Condvar, +} + +/// One GPU render slot, given back on drop. +struct GpuSlot<'a>(&'a GpuSlots); + +static GPU_SLOTS: GpuSlots = GpuSlots::new(); + +impl GpuSlots { + const fn new() -> Self { + Self { busy: Mutex::new(0), freed: Condvar::new() } + } + + /// Wait until fewer than `max` renders are in flight, then take a slot. The browser has no + /// threads to wait on: it never blocks. + fn acquire(&self, max: usize) -> GpuSlot<'_> { + let mut busy = self.busy.lock().unwrap_or_else(PoisonError::into_inner); + while !cfg!(target_arch = "wasm32") && *busy >= max { + busy = self.freed.wait(busy).unwrap_or_else(PoisonError::into_inner); + } + *busy += 1; + GpuSlot(self) + } +} + +impl Drop for GpuSlot<'_> { + fn drop(&mut self) { + let mut busy = self.0.busy.lock().unwrap_or_else(PoisonError::into_inner); + *busy = busy.saturating_sub(1); + self.0.freed.notify_one(); + } +} + /// A rendered viewer frame: CPU pixels or a GPU texture. #[derive(Clone)] pub enum FrameImage { @@ -1458,7 +1498,10 @@ impl Worker { r.cache = Some(&job.src.layer_cache); r.accel = Some(g as &dyn effectcraft_engine::render::Accelerator); r.active_accel()?; - let f = g.within_memory(|| g.render_display(&r, job.comp, job.t)); + let f = { + let _slot = GPU_SLOTS.acquire(MAX_GPU_RENDERS); + g.within_memory(|| g.render_display(&r, job.comp, job.t)) + }; effectcraft_engine::render::Accelerator::frame_end(g); match f { Ok(f) => { @@ -1497,7 +1540,11 @@ impl Worker { let auto = if cfg!(target_arch = "wasm32") { None } else { r.frame_auto(a, job.comp, true) }; if auto.is_none_or(|(p, key)| p.choose(key)) { let t0 = web_time::Instant::now(); - match g.within_memory(|| g.render_display(&r, job.comp, job.t)) { + let shown = { + let _slot = GPU_SLOTS.acquire(MAX_GPU_RENDERS); + g.within_memory(|| g.render_display(&r, job.comp, job.t)) + }; + match shown { Ok(Some(f)) => { self.gpu_succeeded(); if let Some((p, key)) = auto { @@ -1530,7 +1577,11 @@ impl Worker { } if let Some(g) = gpu { // The GPU composites and the frame is read back. - return match g.within_memory(|| r.comp_frame(job.comp, job.t)) { + let composited = { + let _slot = GPU_SLOTS.acquire(MAX_GPU_RENDERS); + g.within_memory(|| r.comp_frame(job.comp, job.t)) + }; + return match composited { Ok(img) => { self.gpu_succeeded(); FrameImage::Cpu(Arc::new(to_color_image(&img))) diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index ace51b0bb..dde9225c5 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -1113,3 +1113,25 @@ fn timing_markers_and_expressions_still_change_the_frames() { s.execute("layer.addMarker", json!({"comp": outer.0, "layers": [solid], "time": 0.75})).unwrap(); assert_ne!(at(&s), o2, "an expression can read the marker, so the frames change"); } + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_renders_in_flight_stay_within_the_cap() { + use std::sync::atomic::AtomicUsize; + let slots = GpuSlots::new(); + let now = AtomicUsize::new(0); + let peak = AtomicUsize::new(0); + std::thread::scope(|s| { + for _ in 0..16 { + s.spawn(|| { + let _slot = slots.acquire(2); + let n = now.fetch_add(1, Ordering::SeqCst) + 1; + peak.fetch_max(n, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(5)); + now.fetch_sub(1, Ordering::SeqCst); + }); + } + }); + assert!(peak.load(Ordering::SeqCst) <= 2); + assert_eq!(*slots.busy.lock().unwrap_or_else(PoisonError::into_inner), 0); +} diff --git a/docs/architecture.md b/docs/architecture.md index c4bebd9ed..7637dbc28 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -362,7 +362,9 @@ as NaNs (a kernel that skips pixels fails the oracle) and validation errors pani (`ui-egui::frames`); headless renders, the CLI (unless `--gpu`) and CI use the CPU. Outside tests, device errors are logged rather than fatal. A viewer frame that runs out of video memory (`Gpu::within_memory`) renders on the CPU, the uploaded layers and pooled textures are freed, and -the RAM preview keeps half as many GPU frames from then on. The same guard catches a frame's +the RAM preview keeps half as many GPU frames from then on. However many frame threads the viewer +runs, at most two of them render on the GPU at once and the rest wait their turn, so their working +textures don't add up to more video memory than the GPU has (#3028). The same guard catches a frame's validation errors (an invalid texture, often left by a failed allocation): that frame renders on the CPU and the kept textures are freed, but the compositor stays on and, with no allocation failure before it, the RAM preview keeps its GPU frames (#1319). A frame whose render panics is From 59c41fee0a80c462a2443f9214ddf1035aa67b15 Mon Sep 17 00:00:00 2001 From: FredApps Date: Mon, 9 Nov 2026 20:21:23 +0100 Subject: [PATCH 2/2] #3028: keep dedicated preview lanes waiting on GPU admission --- crates/ui-egui/src/frames.rs | 26 ++++++++-- crates/ui-egui/src/frames/tests.rs | 78 ++++++++++++++++++++++++++++++ docs/architecture.md | 3 +- 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 0efcab351..32f0dd161 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -101,6 +101,12 @@ impl GpuRenderSlots { /// A Rayon helper may be executing a nested frame while an admitted render waits for /// its work. Blocking that helper on a permit would deadlock the render (#3028). fn for_render(&self, max: usize) -> Option> { + // Dedicated preview pools have one thread, protected from nested drains. They cannot + // be helpers of another admitted frame, so waiting preserves GPU preview throughput. + #[cfg(not(target_arch = "wasm32"))] + if DRAINING_LANE.get() { + return Some(self.acquire(max)); + } if rayon::current_thread_index().is_some() { self.try_acquire(max) } else { Some(self.acquire(max)) } } @@ -1686,15 +1692,29 @@ struct Worker { thread_local! { /// This pool thread is inside [`Worker::drain`] (rendering a frame). static DRAINING: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Only the single thread of a dedicated preview lane may wait for GPU admission. + static DRAINING_LANE: std::cell::Cell = const { std::cell::Cell::new(false) }; } /// Clears [`DRAINING`] when the drain ends, even by a panic. #[cfg(not(target_arch = "wasm32"))] struct Draining; +#[cfg(not(target_arch = "wasm32"))] +impl Draining { + fn enter(lane: Option) -> Option { + if DRAINING.replace(true) { + return None; + } + DRAINING_LANE.set(lane.is_some()); + Some(Self) + } +} + #[cfg(not(target_arch = "wasm32"))] impl Drop for Draining { fn drop(&mut self) { + DRAINING_LANE.set(false); DRAINING.set(false); } } @@ -1723,11 +1743,7 @@ impl Worker { /// `lane`: the lane this runs on (see [`Queue::idle_lanes`]), `None` on the pool. #[cfg(not(target_arch = "wasm32"))] fn drain(&self, lane: Option) { - if DRAINING.get() { - return; - } - DRAINING.set(true); - let _reset = Draining; + let Some(_reset) = Draining::enter(lane) else { return }; while self.run_next(lane) {} } diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index cb3e41f21..0a148b83c 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -3237,6 +3237,42 @@ fn gpu_worker_routes_admit_display_readback_and_auto_effects() { assert!(worker.gpu_ready()); assert!(gpu.context().check_health().is_ok()); + // The real dedicated-lane drain waits, then caches a GPU frame instead of silently + // switching a healthy preview to CPU rendering while the other lanes are busy. + frames.queue.lock().unwrap().jobs.push(Job { + key: job.key, + src: job.src.clone(), + comp, + t: job.t, + opts: RenderOpts { backend: Backend::Gpu, ..job.opts }, + urgent: false, + background: true, + seq: 1, + cancel: Arc::default(), + }); + let first = GPU_RENDER_SLOTS.acquire(MAX_GPU_RENDERS); + let second = GPU_RENDER_SLOTS.acquire(MAX_GPU_RENDERS); + let pool = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap(); + let (started, ready) = std::sync::mpsc::channel(); + let (tx, rx) = std::sync::mpsc::channel(); + let (early, finished) = std::thread::scope(|scope| { + scope.spawn(|| { + pool.install(|| { + started.send(()).unwrap(); + worker.drain(Some(0)); + tx.send(()).unwrap(); + }) + }); + ready.recv_timeout(std::time::Duration::from_secs(2)).unwrap(); + let early = rx.recv_timeout(std::time::Duration::from_millis(100)); + drop((first, second)); + let finished = early.or_else(|_| rx.recv_timeout(std::time::Duration::from_secs(2))); + (early, finished) + }); + assert!(early.is_err(), "preview lane must wait for an available GPU permit"); + assert_eq!(finished, Ok(())); + assert!(frames.cache.lock().unwrap().map.get(&job.key).unwrap().img.is_gpu()); + // A retirement found inside the scope releases the permit before the fresh CPU retry. gpu.context().device_failure_notifier()("synthetic worker retirement".into()); let before = admissions(); @@ -3288,3 +3324,45 @@ fn gpu_render_slots_never_block_rayon_helpers() { assert_eq!(declined, Ok(true), "Rayon helpers must not wait on GPU permits"); assert_eq!(*slots.busy.lock().unwrap(), 0); } + +/// Dedicated preview lanes have one protected thread and can wait without creating the +/// shared-pool helper dependency cycle. They must keep rendering on a healthy GPU. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_preview_lane_waits_for_admission_and_resets_its_marker() { + let slots = GpuRenderSlots::new(); + let first = slots.acquire(2); + let second = slots.acquire(2); + let pool = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap(); + let (started, ready) = std::sync::mpsc::channel(); + let (tx, rx) = std::sync::mpsc::channel(); + let (early, admitted) = std::thread::scope(|scope| { + scope.spawn(|| { + pool.install(|| { + let _drain = Draining::enter(Some(0)).unwrap(); + assert!(Draining::enter(None).is_none(), "a lane cannot start a nested drain"); + assert!(DRAINING_LANE.get(), "nested rejection must not clear the lane marker"); + started.send(()).unwrap(); + tx.send(slots.for_render(2).is_some()).unwrap(); + }); + }); + ready.recv_timeout(std::time::Duration::from_secs(2)).unwrap(); + let early = rx.recv_timeout(std::time::Duration::from_millis(100)); + drop((first, second)); + let admitted = early.or_else(|_| rx.recv_timeout(std::time::Duration::from_secs(2))); + (early, admitted) + }); + assert!(early.is_err(), "busy dedicated lane must wait instead of falling back to CPU"); + assert_eq!(admitted, Ok(true)); + pool.install(|| { + assert!(!DRAINING.get()); + assert!(!DRAINING_LANE.get()); + let panic = std::panic::catch_unwind(|| { + let _drain = Draining::enter(Some(0)).unwrap(); + panic!("synthetic lane failure"); + }); + assert!(panic.is_err()); + assert!(!DRAINING.get()); + assert!(!DRAINING_LANE.get()); + }); +} diff --git a/docs/architecture.md b/docs/architecture.md index 32949351b..195634eb1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -414,7 +414,8 @@ tests, device errors are logged rather than fatal. A viewer frame that runs out (`Gpu::within_memory`) renders on the CPU, the uploaded layers and pooled textures are freed, and the RAM preview keeps half as many GPU frames from then on. However many frame threads the viewer runs, at most two of them start GPU render attempts at once. Shared-pool workers use the CPU -when both slots are busy rather than blocking Rayon helpers; other render threads wait, so +when both slots are busy rather than blocking Rayon helpers; dedicated single-thread preview +lanes wait for admission and remain on the GPU, so working-texture pressure is bounded by concurrent attempts rather than the thread count (#3028). A single large frame or retained frames can still exceed the available video memory. The same guard catches a frame's validation errors (an invalid texture, often left by a failed allocation): that frame renders on