From 9a0a198ff1e69ed8f62061db1cc739abfe9ede58 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Mon, 9 Nov 2026 18:27:04 +0530 Subject: [PATCH] Preview: the window stays smooth while lanes render on the GPU (#3345) Since #595 (v0.19.0) a playing preview and Cache Frames When Idle render one frame per lane, a lane per core, each recording, submitting and reading back a whole frame on the GPU the window draws with. On a GPU that can't keep up, the window froze for hundreds of milliseconds to seconds and the playhead jumped ahead when it came back. Lanes now take a GPU frame only while fewer than a limit of them composite. The limit starts at one lane and adapts to the window's frame gaps while a preview plays: a stall (>100 ms) with lanes compositing halves it, half a second of smooth frames (<50 ms) with every allowed lane busy adds one, up to every lane; a limit that stalled is tried again only after 10 s. Frames on the CPU (Software Only, no or retired GPU, GPU memory pressure) and disk-cache hits use every lane; the pool and the viewer's frame are not limited, and frames ahead of the playhead still start first. --- crates/ui-egui/src/frames.rs | 126 ++++++++++++++++++++-- crates/ui-egui/src/frames/tests.rs | 166 +++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 968b2d791..8bfcd73e8 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -54,6 +54,14 @@ const GPU_BUDGET: usize = 1 << 30; 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); +/// The window's frame gaps that adapt [`Queue::gpu_limit`] while a preview plays: a longer one +/// is a stall, shorter ones for a while are smooth. +const UI_STALL: std::time::Duration = std::time::Duration::from_millis(100); +const UI_SMOOTH: std::time::Duration = std::time::Duration::from_millis(50); +/// How long the window must stay smooth before one more lane composites on the GPU, and before it +/// tries again a limit that stalled. +const GPU_LANE_STEP: std::time::Duration = std::time::Duration::from_millis(500); +const GPU_LANE_RETRY: std::time::Duration = std::time::Duration::from_secs(10); mod f16; pub use f16::{F16_PIXEL_BYTES, F16Frame}; @@ -639,7 +647,7 @@ impl Default for Frames { }) .collect(); #[cfg(not(target_arch = "wasm32"))] - let queue = Queue { idle_lanes: (0..lanes.len()).collect(), lanes: lanes.len(), ..Default::default() }; + let queue = Queue { idle_lanes: (0..lanes.len()).collect(), lanes: lanes.len(), gpu_limit: 1, ..Default::default() }; #[cfg(target_arch = "wasm32")] let queue = Queue::default(); Frames { @@ -1282,6 +1290,8 @@ impl Frames { #[cfg(not(target_arch = "wasm32"))] let moved = q.playhead.is_some() != playhead.is_some(); q.playhead = playhead; + // (called once per frame of the window while a preview plays) + let more_gpu = q.ui_frame(playhead.map(|_| web_time::Instant::now())); if let Some(p) = playhead && let Ok(mut inf) = self.inflight.lock() { @@ -1293,6 +1303,13 @@ impl Frames { !gone }); } + // A lane more may composite: one that stopped at the limit starts again. + #[cfg(not(target_arch = "wasm32"))] + if more_gpu && !moved { + self.start_lanes(&mut q, 1); + } + #[cfg(target_arch = "wasm32")] + let _ = more_gpu; #[cfg(not(target_arch = "wasm32"))] if moved { self.start_lanes(&mut q, usize::MAX); @@ -1610,6 +1627,17 @@ impl Drop for Draining { } } +/// A lane's hold on one of the [`Queue::gpu_limit`] GPU slots (see [`Queue::gpu_lanes`]), given back when dropped, +/// even by a panic. +struct GpuSlot<'a>(&'a Mutex); + +impl Drop for GpuSlot<'_> { + fn drop(&mut self) { + let mut q = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + q.gpu_lanes = q.gpu_lanes.saturating_sub(1); + } +} + impl Worker { /// Render queued jobs, most important first, until the queue is empty. /// @@ -1636,19 +1664,25 @@ impl Worker { /// idle again. fn run_next(&self, lane: Option) -> bool { let Worker { queue, content_keys, .. } = self; - let job = { + // (asked before the queue's lock: it takes the cache's) + let gpu = lane.is_some() && self.gpu_ready(); + let (job, mut slot) = { let Ok(mut q) = queue.lock() else { return false }; - let Some(i) = q.next(lane.is_some()) else { + let Some(i) = q.next(lane.is_some(), gpu) else { q.idle_lanes.extend(lane); return false; }; let job = q.jobs.swap_remove(i); + let slot = (gpu && q.gpu_job(&job)).then(|| { + q.gpu_lanes += 1; + GpuSlot(queue) + }); q.running.insert(job.key); if job.urgent { q.urgent_running += 1; q.urgent_cancels.push((job.key, job.cancel.clone())); } - job + (job, slot) }; let t0 = web_time::Instant::now(); // (the drop generation before the frame reads anything: File ▸ Reload Footage meanwhile @@ -1657,6 +1691,10 @@ impl Worker { job.src.disk.as_ref().map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts)), dc.drop_generation())); let from_disk = disk.as_ref().and_then(|(dc, k, _)| self.disk_frame(dc, *k, &job)); let disk_hit = from_disk.is_some(); + if disk_hit { + // Nothing for the GPU to do: another lane may composite meanwhile. + slot = None; + } // `files`: the source files the frame was made of (see `render::reads`). let (ci, files) = match from_disk { Some(hit) => hit, @@ -1683,6 +1721,8 @@ impl Worker { // and compression. Persistence still runs on this worker, using the same snapshot. let since = disk.as_ref().map(|(dc, _, since)| DiskSince::of(dc, *since)); self.finish(&job, ci.clone(), Some(files.clone()), since, t0); + // The GPU's part is done; storing it on disk is the CPU's. + drop(slot); if !disk_hit && let Some((dc, k, since)) = &disk && put_disk_frame(dc, *k, &ci, &files, self.virtual_id.as_deref(), *since) @@ -1951,6 +1991,24 @@ struct Queue { /// caching left the same gaps, and held up the viewer's frame when it shared a piece. lanes: usize, idle_lanes: Vec, + /// Lanes compositing a frame on the GPU, at most [`Queue::gpu_limit`]. The GPU is the + /// viewer's device: each lane records, submits and reads back a whole frame on it, and the + /// window can't draw until that work is done. With a lane per core on a GPU that can't keep + /// up, the window froze for hundreds of milliseconds at a time while a preview played, and + /// the preview jumped ahead when it came back (#3345). The other lanes go on with the frames + /// that don't need the GPU (the disk cache's, or all of them while the GPU is out of memory + /// or retired); the pool and the viewer's frame are not counted. + gpu_lanes: usize, + /// How many lanes may composite on the GPU at a time (at least 1, see [`Queue::ui_frame`]). + gpu_limit: usize, + /// The limit that last stalled the window: tried again only after [`GPU_LANE_RETRY`]. + gpu_stalled_at: usize, + /// While a preview plays: the window's last frame, and since when it has been smooth. + ui_last: Option, + ui_smooth_since: Option, + /// Tests: as if there were a GPU compositor (no adapter is there). + #[cfg(test)] + fake_gpu: bool, } impl Queue { @@ -1959,9 +2017,63 @@ impl Queue { self.lanes > 0 && !j.urgent && (j.background || self.playhead.is_some()) } - /// The job a lane (`lane`) or the pool renders next: the [`Queue::best`] of its own. - fn next(&self, lane: bool) -> Option { - self.best_of(|j| self.on_lane(j) == lane) + /// The job a lane (`lane`) or the pool renders next: the [`Queue::best`] of its own. `gpu`: + /// the GPU composites (a lane's frames), so a lane takes no GPU frame while + /// [`Queue::gpu_limit`] lanes are compositing. + fn next(&self, lane: bool, gpu: bool) -> Option { + let gpu_full = gpu && self.gpu_lanes >= self.gpu_limit.max(1); + self.best_of(|j| self.on_lane(j) == lane && !(gpu_full && self.gpu_job(j))) + } + + /// The window drew a frame at `now` (`None`: no preview plays). A window that stalls with + /// lanes compositing gets fewer of them: the GPU can't keep up with them and the window. One + /// that stays smooth while every allowed lane composites gets one more, up to all of them: a + /// GPU that keeps up renders the preview on every lane. A limit that stalled is tried again + /// only after a longer smooth spell, so the window doesn't stall over and over. `true` when + /// one more lane may composite. + fn ui_frame(&mut self, now: Option) -> bool { + let Some(now) = now else { + self.ui_last = None; + return false; + }; + let limit = self.gpu_limit.max(1); + let gap = self.ui_last.map(|last| now.saturating_duration_since(last)); + self.ui_last = Some(now); + let Some(gap) = gap else { + self.ui_smooth_since = Some(now); + return false; + }; + if gap > UI_STALL && self.gpu_lanes > 0 { + self.gpu_stalled_at = limit; + self.gpu_limit = (limit / 2).max(1); + self.ui_smooth_since = Some(now); + } else if gap > UI_SMOOTH { + self.ui_smooth_since = Some(now); + } else { + let since = *self.ui_smooth_since.get_or_insert(now); + let wait = if limit + 1 >= self.gpu_stalled_at && self.gpu_stalled_at > 0 { GPU_LANE_RETRY } else { GPU_LANE_STEP }; + if self.gpu_lanes >= limit && limit < self.lanes && now.saturating_duration_since(since) >= wait { + self.gpu_limit = limit + 1; + self.ui_smooth_since = Some(now); + return true; + } + } + false + } + + /// Whether `j` is composited on the GPU when the GPU is ready: there is a GPU compositor and + /// the frame's renderer uses it (as `Renderer::active_accel` decides: Software Only keeps + /// every frame on the CPU). + fn gpu_job(&self, j: &Job) -> bool { + let adapter = j.src.gpu.is_some(); + #[cfg(test)] + let adapter = adapter || self.fake_gpu; + adapter + && match j.opts.backend { + effectcraft_engine::render::Backend::Cpu => false, + effectcraft_engine::render::Backend::Gpu => true, + effectcraft_engine::render::Backend::Auto => j.src.project.settings.gpu_acceleration, + } } /// The job that matters most: urgent first (newest), then, while a preview plays, the diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index 360ddf1b6..d980f8565 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -610,15 +610,21 @@ struct ParallelClip { started: std::sync::atomic::AtomicUsize, /// Frames started by the time frame 1 was done. started_at_first: std::sync::atomic::AtomicUsize, + /// Frames decoding now, and the most at a time. + active: std::sync::atomic::AtomicUsize, + most_active: std::sync::atomic::AtomicUsize, } impl FootageSource for ParallelClip { fn frame(&self, _: ItemId, _: &effectcraft_engine::project::Footage, t: Tick) -> Option> { self.started.fetch_add(1, Ordering::SeqCst); + let active = self.active.fetch_add(1, Ordering::SeqCst) + 1; + self.most_active.fetch_max(active, Ordering::SeqCst); (0..16).into_par_iter().for_each(|_| (0..4).into_par_iter().for_each(|_| std::thread::sleep(std::time::Duration::from_millis(1)))); if effectcraft_engine::time::FrameRate::FPS_30.frame_at(t) == 1 { self.started_at_first.store(self.started.load(Ordering::SeqCst), Ordering::SeqCst); } + self.active.fetch_sub(1, Ordering::SeqCst); Some(Arc::new(effectcraft_engine::render::Image::new(4, 4))) } } @@ -729,6 +735,166 @@ fn queued_frames_render_when_a_preview_starts_or_stops() { assert_eq!(f.cached_frames(&series).len(), 119); } +/// Waits for the lanes to be idle (after their last frame they give back their GPU slot) and +/// checks every GPU slot was given back. +fn wait_for_idle_lanes(f: &Frames) { + for _ in 0..2000 { + let q = f.queue.lock().unwrap(); + if q.idle_lanes.len() == f.lanes.len() { + break; + } + drop(q); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let q = f.queue.lock().unwrap(); + assert_eq!(q.idle_lanes.len(), f.lanes.len(), "every lane is idle again"); + assert_eq!(q.gpu_lanes, 0, "every lane gave its GPU slot back"); +} + +/// Plays a preview of `frames` frames of a [`parallel_clip`] as the viewer renders them (the +/// project's renderer: `backend` Auto) or with `backend`, as if there were a GPU compositor +/// (`gpu`), `retired` or with the project on Mercury Software Only (`!gpu_acceleration`), and +/// waits for them: the frame cache and the most frames that rendered at a time. +fn play_preview(gpu: bool, retired: bool, backend: effectcraft_engine::render::Backend, gpu_acceleration: bool) -> (Frames, usize) { + use effectcraft_engine::time::FrameRate; + let (f, footage, mut src, cid, series) = parallel_clip(); + Arc::make_mut(&mut src.project).settings.gpu_acceleration = gpu_acceleration; + f.queue.lock().unwrap().fake_gpu = gpu; + if retired { + f.retire_gpu(); + } + let opts = RenderOpts { backend, ..RenderOpts::default() }; + f.set_playhead(Some(Playhead { series, cur: 0, start: 0, end: 299, step: 1, looping: false })); + let frames = 24; + for frame in 1..=frames { + f.request(&src, FrameKey { frame, ..series }, cid, FrameRate::FPS_30.tick_of(frame), opts); + } + wait_for_renders(&f); + assert_eq!(f.cached_frames(&series).len(), frames as usize); + wait_for_idle_lanes(&f); + let most = footage.most_active.load(Ordering::SeqCst); + (f, most) +} + +/// Every lane composited a playing preview's frame on the GPU at the same time, on the device +/// the window draws with: the window froze for hundreds of milliseconds and the preview jumped +/// ahead (#3345). A preview starts on one GPU lane (see [`Queue::ui_frame`]). +#[test] +fn a_preview_composites_on_a_few_lanes_at_a_time() { + use effectcraft_engine::render::Backend; + for backend in [Backend::Auto, Backend::Gpu] { + let (_, most) = play_preview(true, false, backend, true); + assert!(most <= 1, "{most} lanes composited on the GPU at once ({backend:?})"); + } +} + +/// Frames that render on the CPU use every lane: with no GPU compositor, with the GPU retired, +/// and with Mercury Software Only (the project's renderer, or the CPU backend). +#[test] +fn frames_on_the_cpu_render_on_every_lane() { + use effectcraft_engine::render::Backend; + for (gpu, retired, backend, gpu_acceleration) in + [(false, false, Backend::Auto, true), (true, true, Backend::Auto, true), (true, false, Backend::Auto, false), (true, false, Backend::Cpu, true)] + { + let (f, most) = play_preview(gpu, retired, backend, gpu_acceleration); + if f.lanes.len() > 2 { + assert!(most > 2, "frames on the CPU rendered on {most} lanes at a time ({gpu} {retired} {backend:?} {gpu_acceleration})"); + } + } +} + +/// A queue of `lanes` lanes, `busy` of them compositing on the GPU. +fn gpu_queue(lanes: usize, busy: usize) -> Queue { + Queue { lanes, gpu_lanes: busy, gpu_limit: 1, ..Default::default() } +} + +/// Window frames every `gap` ms from `t` for `ms` ms; the time after the last. +fn ui_frames(q: &mut Queue, t: web_time::Instant, gap: u64, ms: u64) -> web_time::Instant { + let mut t = t; + for _ in 0..ms / gap { + t += std::time::Duration::from_millis(gap); + q.ui_frame(Some(t)); + } + t +} + +/// A GPU that keeps up with the window gets every lane, one more each half second the window +/// stays smooth with every allowed lane compositing. +#[test] +fn a_smooth_window_gives_the_gpu_every_lane() { + let mut q = gpu_queue(8, 0); + let t = web_time::Instant::now(); + q.ui_frame(Some(t)); + // Nothing compositing: the limit isn't what holds the preview back. + let t = ui_frames(&mut q, t, 16, 2000); + assert_eq!(q.gpu_limit, 1); + let mut t = t; + for _ in 0..20 { + q.gpu_lanes = q.gpu_limit; + t = ui_frames(&mut q, t, 16, 520); + } + assert_eq!(q.gpu_limit, 8, "no more than the lanes"); +} + +/// A window that stalls while lanes composite halves the limit, and the limit that stalled is +/// tried again only after a long smooth spell; a window that is only slow keeps the limit. +#[test] +fn a_stalling_window_takes_lanes_from_the_gpu() { + let mut q = gpu_queue(8, 4); + q.gpu_limit = 4; + let t = web_time::Instant::now(); + q.ui_frame(Some(t)); + let t = ui_frames(&mut q, t, 300, 300); + assert_eq!((q.gpu_limit, q.gpu_stalled_at), (2, 4)); + let t = ui_frames(&mut q, t, 80, 5000); + assert_eq!(q.gpu_limit, 2, "a slow (not stalling) window keeps the limit"); + q.gpu_lanes = 2; + let t = ui_frames(&mut q, t, 16, 600); + assert_eq!(q.gpu_limit, 3); + q.gpu_lanes = 3; + let t = ui_frames(&mut q, t, 16, 2000); + assert_eq!(q.gpu_limit, 3, "4 stalled: it waits longer"); + ui_frames(&mut q, t, 16, 10_000); + assert_eq!(q.gpu_limit, 4); + // A stall with nothing compositing isn't the GPU's. + let mut q = gpu_queue(8, 0); + q.gpu_limit = 4; + let t = web_time::Instant::now(); + q.ui_frame(Some(t)); + ui_frames(&mut q, t, 500, 500); + assert_eq!(q.gpu_limit, 4); + // Never below one lane; a stopped preview's pause is no stall. + let mut q = gpu_queue(8, 1); + let t = web_time::Instant::now(); + q.ui_frame(Some(t)); + ui_frames(&mut q, t, 400, 400); + assert_eq!(q.gpu_limit, 1); + q.gpu_limit = 2; + q.ui_frame(None); + q.ui_frame(Some(t + std::time::Duration::from_secs(5))); + assert_eq!(q.gpu_limit, 2); +} + +/// A preview that stops while lanes composite: their frames finish, the queued ones move to the +/// pool and render, the viewer's frame is not held up, and every GPU slot is given back. +#[test] +fn a_preview_stopping_mid_render_gives_the_gpu_slots_back() { + use effectcraft_engine::time::FrameRate; + let (f, _, src, cid, series) = parallel_clip(); + f.queue.lock().unwrap().fake_gpu = true; + let opts = RenderOpts { backend: effectcraft_engine::render::Backend::Auto, ..RenderOpts::default() }; + f.set_playhead(Some(Playhead { series, cur: 0, start: 0, end: 299, step: 1, looping: false })); + for frame in 1..=24 { + f.request(&src, FrameKey { frame, ..series }, cid, FrameRate::FPS_30.tick_of(frame), opts); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + f.set_playhead(None); + f.request_urgent(&src, FrameKey { frame: 100, ..series }, cid, FrameRate::FPS_30.tick_of(100), opts); + wait_for_renders(&f); + assert_eq!(f.cached_frames(&series).len(), 25); + wait_for_idle_lanes(&f); +} + /// The cache is full. A zoom (or preview-resolution) change left the frames of the old /// size to the plain least-recently-shown order, so the frames of the zoom the viewer is back /// at went before the ones of the abandoned zoom. The other size's frames go first, then the