mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-09 22:23:17 +00:00
Merge pull request #3355 from arnav-hiwarkar/fix/3345-gpu-preview-lanes
Preview: the window stays smooth while lanes render on the GPU (#3345)
This commit is contained in:
@@ -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<Queue>);
|
||||
|
||||
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<usize>) -> 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<usize>,
|
||||
/// 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<web_time::Instant>,
|
||||
ui_smooth_since: Option<web_time::Instant>,
|
||||
/// 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<usize> {
|
||||
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<usize> {
|
||||
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<web_time::Instant>) -> 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
|
||||
|
||||
@@ -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<Arc<effectcraft_engine::render::Image>> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user