Merge pull request #129 from storytold/fix/106-gpu-out-of-memory

GPU: running out of video memory falls back to the CPU instead of crashing (#106)
This commit is contained in:
BFlat 2026-10-07 02:06:46 +07:00 committed by GitHub
commit 9e619d2893
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 198 additions and 29 deletions

View File

@ -291,6 +291,20 @@ fn storage_tex_entry(binding: u32, format: wgpu::TextureFormat) -> wgpu::BindGro
} }
} }
/// Log the device's uncaptured errors instead of wgpu's default panic.
fn log_uncaptured_errors(device: &wgpu::Device) {
device.on_uncaptured_error(Arc::new(|e| {
// Under test a validation error (a WGSL typo invalidates every kernel) fails loudly.
#[cfg(test)]
#[allow(clippy::panic)] // test builds only
{
panic!("wgpu: {e}")
}
#[cfg(not(test))]
log::error!("wgpu: {e}")
}));
}
impl GpuContext { impl GpuContext {
/// Build on an existing device (the desktop app shares egui-wgpu's). `Err` when the /// Build on an existing device (the desktop app shares egui-wgpu's). `Err` when the
/// adapter cannot run the compositor (no compute shaders, e.g. WebGL2; no float storage /// adapter cannot run the compositor (no compute shaders, e.g. WebGL2; no float storage
@ -469,6 +483,12 @@ impl GpuContext {
let dummy_buf = let dummy_buf =
device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("dummy"), contents: &[0u8; 16], usage: wgpu::BufferUsages::STORAGE }); device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("dummy"), contents: &[0u8; 16], usage: wgpu::BufferUsages::STORAGE });
let max_dim = device.limits().max_texture_dimension_2d; let max_dim = device.limits().max_texture_dimension_2d;
// From here on errors are logged, not fatal: wgpu's default handler panics, and egui-wgpu
// installs none on the device the desktop app shares. Running out of video memory on a
// frame thread must not crash the app (#106); frames catch it with
// [`crate::Gpu::within_memory`]. (Errors while the pipelines are built above still panic
// on that device, which the app catches to fall back to the CPU.)
log_uncaptured_errors(&device);
Ok(GpuContext { Ok(GpuContext {
device, device,
queue, queue,
@ -534,16 +554,7 @@ impl GpuContext {
.request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, ..Default::default() }) .request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, ..Default::default() })
.await .await
.map_err(|e| format!("no device: {e}"))?; .map_err(|e| format!("no device: {e}"))?;
device.on_uncaptured_error(Arc::new(|e| { log_uncaptured_errors(&device);
// Under test a validation error (a WGSL typo invalidates every kernel) fails loudly.
#[cfg(test)]
#[allow(clippy::panic)] // test builds only
{
panic!("wgpu: {e}")
}
#[cfg(not(test))]
log::error!("wgpu: {e}")
}));
GpuContext::new(&adapter, device, queue) GpuContext::new(&adapter, device, queue)
} }

View File

@ -244,6 +244,27 @@ impl Gpu {
} }
} }
/// Run GPU work `f` on this thread, catching the device running out of video memory
/// meanwhile (#106). `Err` (why) means the result is incomplete: a texture that failed to
/// allocate holds nothing. The uploaded layers and pooled textures are then freed, so the
/// next render has room. The browser reports it to the device's error handler instead.
pub fn within_memory<T>(&self, f: impl FnOnce() -> T) -> Result<T, String> {
#[cfg(not(target_arch = "wasm32"))]
{
let scope = self.ctx.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let out = f();
match pollster::block_on(scope.pop()) {
Some(e) => {
self.ctx.clear_uploads();
Err(e.to_string())
}
None => Ok(out),
}
}
#[cfg(target_arch = "wasm32")]
Ok(f())
}
/// Wait for all submitted GPU work (benchmarks). /// Wait for all submitted GPU work (benchmarks).
pub fn wait(&self) { pub fn wait(&self) {
let _ = self.ctx.device.poll(wgpu::PollType::wait_indefinitely()); let _ = self.ctx.device.poll(wgpu::PollType::wait_indefinitely());

View File

@ -688,3 +688,11 @@ fn devices_with_webgl2_limits_are_declined() {
let err = crate::context::GpuContext::new(&adapter, device, queue).err().expect("declined"); let err = crate::context::GpuContext::new(&adapter, device, queue).err().expect("declined");
assert!(err.contains("max_storage_buffers_per_shader_stage"), "{err}"); assert!(err.contains("max_storage_buffers_per_shader_stage"), "{err}");
} }
/// GPU work inside the video memory guard returns its result when the device has room (#106).
#[test]
fn within_memory_returns_the_work_when_there_is_room() {
let Some(g) = gpu() else { return };
let img = g.within_memory(|| g.ctx.image(64, 32)).expect("room for a 64×32 texture");
assert_eq!((img.width, img.height), (64, 32));
}

View File

@ -36,8 +36,11 @@ use rayon::prelude::*;
/// Comp identities (and the project snapshots behind them) the RAM preview keeps frames of. /// Comp identities (and the project snapshots behind them) the RAM preview keeps frames of.
const MAX_KEEPERS: usize = 64; const MAX_KEEPERS: usize = 64;
/// GPU frames kept for RAM preview (bytes of video memory). /// GPU frames kept for RAM preview (bytes of video memory) at first. wgpu can't tell how much
/// video memory there is: each time the device runs out, the budget halves (to
/// [`MIN_GPU_BUDGET`]) and the frame renders on the CPU (#106).
const GPU_BUDGET: usize = 1 << 30; const GPU_BUDGET: usize = 1 << 30;
const MIN_GPU_BUDGET: usize = 64 << 20;
/// A rendered viewer frame: CPU pixels or a GPU texture. /// A rendered viewer frame: CPU pixels or a GPU texture.
#[derive(Clone)] #[derive(Clone)]
@ -112,6 +115,8 @@ struct Cache {
/// Bytes of GPU frames (also counted in `bytes`). /// Bytes of GPU frames (also counted in `bytes`).
gpu_bytes: usize, gpu_bytes: usize,
budget: usize, budget: usize,
/// Bytes of GPU frames to keep at most ([`GPU_BUDGET`]).
gpu_budget: usize,
} }
impl Cache { impl Cache {
@ -130,14 +135,22 @@ impl Cache {
let Some(old) = self.order.pop_front() else { break }; let Some(old) = self.order.pop_front() else { break };
self.remove(&old); self.remove(&old);
} }
// Video memory: drop the oldest GPU frames past their own budget. self.evict_gpu();
while self.gpu_bytes > GPU_BUDGET { }
/// Video memory: drop the oldest GPU frames past their own budget.
fn evict_gpu(&mut self) {
while self.gpu_bytes > self.gpu_budget {
let Some(i) = self.order.iter().position(|k| self.map.get(k).is_some_and(FrameImage::is_gpu)) else { break }; let Some(i) = self.order.iter().position(|k| self.map.get(k).is_some_and(FrameImage::is_gpu)) else { break };
if let Some(old) = self.order.remove(i) { if let Some(old) = self.order.remove(i) {
self.remove(&old); self.remove(&old);
} }
} }
} }
/// The GPU ran out of memory: keep half as many GPU frames from now on.
fn gpu_out_of_memory(&mut self) {
self.gpu_budget = (self.gpu_budget / 2).max(MIN_GPU_BUDGET);
self.evict_gpu();
}
fn forget(&mut self, img: &FrameImage) { fn forget(&mut self, img: &FrameImage) {
self.bytes -= img.bytes(); self.bytes -= img.bytes();
if img.is_gpu() { if img.is_gpu() {
@ -370,7 +383,7 @@ impl Default for Frames {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 16); let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 16);
Frames { Frames {
cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30 })), cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30, gpu_budget: GPU_BUDGET })),
inflight: Arc::new(Mutex::new(HashSet::new())), inflight: Arc::new(Mutex::new(HashSet::new())),
queue: Arc::new(Mutex::new(Queue::default())), queue: Arc::new(Mutex::new(Queue::default())),
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
@ -786,7 +799,14 @@ impl Worker {
let disk_hit = from_disk.is_some(); let disk_hit = from_disk.is_some();
let ci = match from_disk { let ci = match from_disk {
Some(f) => FrameImage::Cpu(Arc::new(egui::ColorImage::from_rgba_premultiplied([f.width as usize, f.height as usize], &f.rgba))), Some(f) => FrameImage::Cpu(Arc::new(egui::ColorImage::from_rgba_premultiplied([f.width as usize, f.height as usize], &f.rgba))),
None => self.render_job(&job), None => match self.render_guarded(&job) {
Some(ci) => ci,
None => {
// Not "in progress" forever: the viewer asks for it again.
self.release(job.key, job.urgent);
return true;
}
},
}; };
// Publish the shared frame and wake the viewer before optional RGBA conversion // Publish the shared frame and wake the viewer before optional RGBA conversion
// and compression. Persistence still runs on this worker, using the same snapshot. // and compression. Persistence still runs on this worker, using the same snapshot.
@ -832,6 +852,24 @@ impl Worker {
} }
} }
/// [`Worker::render_job`], again on the CPU if it panicked (the panic hook has logged it);
/// `None` if that panicked too.
fn render_guarded(&self, job: &Job) -> Option<FrameImage> {
let attempt = |gpu: bool| std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.render_job(job, gpu))).ok();
attempt(true).or_else(|| {
log::error!("a frame render panicked (frame {})", job.key.frame);
job.src.gpu.as_ref().and_then(|_| attempt(false))
})
}
/// The GPU ran out of video memory rendering a frame (#106): fewer GPU frames are kept.
fn out_of_memory(&self, e: &str) {
log::warn!("the GPU ran out of memory ({e}); the frame renders on the CPU and fewer GPU frames are kept");
if let Ok(mut c) = self.cache.lock() {
c.gpu_out_of_memory();
}
}
/// The frame on the GPU, kept there for the viewer, if the compositor can do it alone. /// The frame on the GPU, kept there for the viewer, if the compositor can do it alone.
fn render_gpu(&self, job: &Job) -> Option<FrameImage> { fn render_gpu(&self, job: &Job) -> Option<FrameImage> {
let g = job.src.gpu.as_ref()?; let g = job.src.gpu.as_ref()?;
@ -840,19 +878,27 @@ impl Worker {
r.cache = Some(&job.src.layer_cache); r.cache = Some(&job.src.layer_cache);
r.accel = Some(g as &dyn effectcraft_engine::render::Accelerator); r.accel = Some(g as &dyn effectcraft_engine::render::Accelerator);
r.active_accel()?; r.active_accel()?;
g.render_display(&r, job.comp, job.t).map(|f| FrameImage::Gpu(Arc::new(f))) match g.within_memory(|| g.render_display(&r, job.comp, job.t)) {
Ok(f) => f.map(|f| FrameImage::Gpu(Arc::new(f))),
Err(e) => {
self.out_of_memory(&e);
None
}
}
} }
fn render_job(&self, job: &Job) -> FrameImage { /// Render a frame; `gpu: false` keeps it off the GPU compositor.
fn render_job(&self, job: &Job, gpu: bool) -> FrameImage {
let mut r = Renderer::new(&job.src.project, job.src.footage.as_ref(), job.opts); let mut r = Renderer::new(&job.src.project, job.src.footage.as_ref(), job.opts);
r.expr = job.src.expr.as_deref(); r.expr = job.src.expr.as_deref();
r.cache = Some(&job.src.layer_cache); r.cache = Some(&job.src.layer_cache);
r.accel = job.src.gpu.as_ref().map(|g| g as &dyn effectcraft_engine::render::Accelerator); let gpu = job.src.gpu.as_ref().filter(|_| gpu);
r.accel = gpu.map(|g| g as &dyn effectcraft_engine::render::Accelerator);
// The GPU leaves the frame in a texture for the viewer; otherwise (Software Only, no // The GPU leaves the frame in a texture for the viewer; otherwise (Software Only, no
// adapter, or a frame the GPU cannot finish here) the CPU renders it. // adapter, or a frame the GPU cannot finish here) the CPU renders it.
// Auto (Mercury GPU Acceleration) shows each comp on whichever compositor measured // Auto (Mercury GPU Acceleration) shows each comp on whichever compositor measured
// faster for it (light comps composite faster on the CPU). // faster for it (light comps composite faster on the CPU).
if let (Some(g), Some(a)) = (&job.src.gpu, r.active_accel()) if let (Some(g), Some(a)) = (gpu, r.active_accel())
&& job.src.gpu_display && job.src.gpu_display
{ {
// The browser cannot wait for the GPU to time it (and its CPU path is slow): always // The browser cannot wait for the GPU to time it (and its CPU path is slow): always
@ -860,16 +906,24 @@ impl Worker {
let auto = if cfg!(target_arch = "wasm32") { None } else { r.frame_auto(a, job.comp, true) }; 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)) { if auto.is_none_or(|(p, key)| p.choose(key)) {
let t0 = web_time::Instant::now(); let t0 = web_time::Instant::now();
if let Some(f) = g.render_display(&r, job.comp, job.t) { match g.within_memory(|| g.render_display(&r, job.comp, job.t)) {
if let Some((p, key)) = auto { Ok(Some(f)) => {
// Time the GPU's work, not just its recording. if let Some((p, key)) = auto {
g.wait(); // Time the GPU's work, not just its recording.
p.record(key, true, t0.elapsed().as_secs_f64() * 1000.0); g.wait();
p.record(key, true, t0.elapsed().as_secs_f64() * 1000.0);
}
return FrameImage::Gpu(Arc::new(f));
}
Ok(None) => {
if let Some((p, key)) = auto {
p.declined(key);
}
}
Err(e) => {
self.out_of_memory(&e);
return self.render_job(job, false);
} }
return FrameImage::Gpu(Arc::new(f));
}
if let Some((p, key)) = auto {
p.declined(key);
} }
} }
let t0 = web_time::Instant::now(); let t0 = web_time::Instant::now();
@ -879,6 +933,16 @@ impl Worker {
} }
return FrameImage::Cpu(Arc::new(img)); return FrameImage::Cpu(Arc::new(img));
} }
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)) {
Ok(img) => FrameImage::Cpu(Arc::new(to_color_image(&img))),
Err(e) => {
self.out_of_memory(&e);
self.render_job(job, false)
}
};
}
FrameImage::Cpu(Arc::new(to_color_image(&r.comp_frame(job.comp, job.t)))) FrameImage::Cpu(Arc::new(to_color_image(&r.comp_frame(job.comp, job.t))))
} }
} }
@ -924,6 +988,67 @@ mod tests {
f.cache.lock().unwrap().insert(k, img()); f.cache.lock().unwrap().insert(k, img());
} }
/// A frame whose render panics (a GPU out of memory used to, on every frame thread) is
/// released, not left "in progress" forever, so the viewer can ask for it again (#106).
#[test]
fn a_frame_whose_render_panics_is_released() {
use effectcraft_engine::Session;
use effectcraft_engine::project::{Footage, FootageKind};
use effectcraft_engine::render::Image;
use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Default)]
struct Explodes(AtomicUsize);
impl FootageSource for Explodes {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
self.0.fetch_add(1, Ordering::SeqCst);
panic!("a decoder bug");
}
}
let mut s = Session::default();
let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
let clip = Footage { kind: FootageKind::Still, width: 8, height: 8, has_video: true, ..Default::default() };
let item = Arc::make_mut(&mut s.project).add_item("clip.png", Default::default(), None, ItemKind::Footage(clip));
s.execute("layer.addItem", json!({"item": item.0})).unwrap();
let footage = Arc::new(Explodes::default());
let src = RenderSource {
project: s.project.clone(),
footage: footage.clone(),
expr: None,
layer_cache: Arc::default(),
gpu: None,
gpu_display: false,
disk: None,
};
let f = Frames::default();
let key = FrameKey { revision: s.revision, content: f.content_of(&s.project, s.revision, cid), ..key(0, 0, 0) };
let key = FrameKey { comp: cid.0, ..key };
f.request(&src, key, cid, Tick::ZERO, RenderOpts::default());
for _ in 0..1000 {
if f.inflight() == 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(footage.0.load(Ordering::SeqCst) > 0, "the render reached the footage");
assert_eq!(f.inflight(), 0, "released");
assert!(!f.is_cached(&key));
}
/// Each time the GPU runs out of memory, half as many GPU frames are kept, down to a floor.
#[test]
fn running_out_of_video_memory_halves_the_gpu_frame_budget() {
let f = Frames::default();
let mut c = f.cache.lock().unwrap();
assert_eq!(c.gpu_budget, GPU_BUDGET);
c.gpu_out_of_memory();
assert_eq!(c.gpu_budget, GPU_BUDGET / 2);
for _ in 0..20 {
c.gpu_out_of_memory();
}
assert_eq!(c.gpu_budget, MIN_GPU_BUDGET);
}
#[test] #[test]
fn least_recently_shown_frames_go_first() { fn least_recently_shown_frames_go_first() {
let f = Frames::default(); let f = Frames::default();

View File

@ -268,7 +268,11 @@ zero texture per size, readback staging buffers are reused, and the 8/16 bpc qua
each layer is fused into the layer's composite kernel; under test, pooled scratch images start each layer is fused into the layer's composite kernel; under test, pooled scratch images start
as NaNs (a kernel that skips pixels fails the oracle) and validation errors panic. The desktop viewer builds the as NaNs (a kernel that skips pixels fails the oracle) and validation errors panic. The desktop viewer builds the
`Gpu` on egui-wgpu's device and shows frames from GPU textures without reading them back `Gpu` on egui-wgpu's device and shows frames from GPU textures without reading them back
(`ui-egui::frames`); headless renders, the CLI (unless `--gpu`) and CI use the CPU. On the web (`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. A frame whose render panics is
rendered again on the CPU or released, never left in progress. On the web
(WebGPU) the GPU composites viewer frames; steps that need a readback fall back to the CPU (the (WebGPU) the GPU composites viewer frames; steps that need a readback fall back to the CPU (the
Info panel's pixel readout reads GPU frames back asynchronously). **GPU particles** Info panel's pixel readout reads GPU frames back asynchronously). **GPU particles**
(`effects::psim`, `gpu::particles`): CC Particle World, CC Particle Systems II and Particle (`effects::psim`, `gpu::particles`): CC Particle World, CC Particle Systems II and Particle