diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 51221abc3..4e55e48c1 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -117,6 +117,8 @@ pub(crate) struct Pool { /// working texture of another `Enc` before it is gone. owned: HashMap>>, next_owner: u64, + /// Owners kept between [`Enc::lent`] calls, each lent to one `Enc` at a time. + idle: Vec, } /// In-`Enc` texture reuse ([`Pool::owned`]); tests turn it off to compare. @@ -215,6 +217,22 @@ impl Pool { self.release(t); } + /// An owner for [`Enc::lent`]: an idle one with its kept textures, or a new one. + fn lend(&mut self) -> u64 { + self.idle.pop().unwrap_or_else(|| self.register()) + } + + /// [`Enc::lent`] is done with `owner` (its work submitted or discarded): keep its + /// textures for the next one, up to the pool budget. + fn give_back(&mut self, owner: u64) { + if let Some(m) = self.owned.get_mut(&owner) + && m.values().flatten().map(tex_bytes).sum::() > POOL_BUDGET + { + m.clear(); + } + self.idle.push(owner); + } + fn take_owned(&mut self, owner: u64, w: u32, h: u32) -> Option { let t = self.owned.get_mut(&owner)?.get_mut(&(w, h))?.pop()?; self.hits += 1; @@ -298,6 +316,8 @@ pub struct GpuContext { uploads: Mutex, upload_budget: std::sync::atomic::AtomicUsize, upload_full_warned: std::sync::atomic::AtomicBool, + /// Back-pressure on frames in flight (see [`crate::memcap`]). + memcap: crate::memcap::MemCap, pool: Arc>, /// CPU ↔ GPU traffic (diagnostics: `bench --gpu`). pub(crate) transfers: Transfers, @@ -645,6 +665,7 @@ impl GpuContext { uploads: Mutex::new(Uploads::default()), upload_budget: std::sync::atomic::AtomicUsize::new(UPLOAD_BUDGET), upload_full_warned: Default::default(), + memcap: crate::memcap::MemCap::new(&info), pool: Default::default(), staging: Default::default(), transfers: Default::default(), @@ -991,6 +1012,23 @@ pub(crate) struct Enc<'g> { missing: bool, /// This `Enc`'s id in the texture pool (textures it may reuse at once). owner: u64, + /// `owner` is lent ([`Enc::lent`]) and outlives this `Enc`. + lent: bool, +} + +impl Enc<'_> { + /// Memory back-pressure (see [`crate::memcap`]): once the allocator holds more than the + /// cap, submit this frame's work and wait for the queue so wgpu frees what was dropped. + pub(crate) fn mem_gate(&mut self) { + let Some(cap) = self.g.memcap.due() else { return }; + let Some(r) = self.g.device.generate_allocator_report() else { return }; + if r.total_allocated_bytes <= cap { + return; + } + self.g.memcap.waited(); + self.submit(); + let _ = self.g.poll_readbacks(wgpu::PollType::Wait { submission_index: None, timeout: Some(crate::readback::TIMEOUT) }); + } } impl Drop for Enc<'_> { @@ -1000,7 +1038,11 @@ impl Drop for Enc<'_> { if let Some((_, id)) = self.enc.take() { p.close(id); } - p.unregister(self.owner); + if self.lent { + p.give_back(self.owner); + } else { + p.unregister(self.owner); + } } } } @@ -1008,7 +1050,18 @@ impl Drop for Enc<'_> { impl<'g> Enc<'g> { pub fn new(g: &'g GpuContext) -> Enc<'g> { let owner = g.pool.lock().map(|mut p| p.register()).unwrap_or(0); - Enc { g, enc: None, pending: 0, missing: false, owner } + Enc { g, enc: None, pending: 0, missing: false, owner, lent: false } + } + + /// An `Enc` for self-contained work that ends in a readback (submitted and waited for, or + /// discarded), whose images never reach another `Enc`: it reuses the working textures of + /// earlier such work at once. A plain [`Enc::new`] hands its textures back through the + /// shared pool, where they wait for every open encoder, and a GPU frame keeps its encoder + /// open while the CPU renders layers with GPU effects, so every effect's intermediates + /// stayed allocated until the frame ended. + pub(crate) fn lent(g: &'g GpuContext) -> Enc<'g> { + let owner = g.pool.lock().map(|mut p| p.lend()).unwrap_or(0); + Enc { g, enc: None, pending: 0, missing: false, owner, lent: true } } /// Whether a dispatch since the last call named a kernel the device doesn't have (one diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 42d2bab71..6f00c29dc 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -69,6 +69,7 @@ pub(crate) fn run_chain(e: &mut Enc, chain: &[FxStep], buf: &Buf, levels: Option e.take_missing(); for step in chain { b = apply(e, step.spec.id, &step.ctx, b)?; + e.mem_gate(); if e.take_missing() { return None; } @@ -126,6 +127,7 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> { self.e.take_missing(); for step in steps { let Some(next) = apply(self.e, step.spec.id, &step.ctx, cur) else { return false }; + self.e.mem_gate(); // A kernel the device couldn't build: the effects render on the CPU (#613). if self.e.take_missing() { return false; diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 252145c14..fc3f07b7e 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -79,6 +79,7 @@ mod fx_tone; mod fx_transition; mod fx_vr; mod fx_warp; +mod memcap; mod ops; mod particles; mod readback; @@ -341,7 +342,7 @@ impl Accelerator for Gpu { fn effects(&self, chain: &[FxStep], buf: &Buf, levels: Option) -> Option { self.ctx.check_health().ok()?; - effects::run_chain(&mut Enc::new(&self.ctx), chain, buf, levels) + effects::run_chain(&mut Enc::lent(&self.ctx), chain, buf, levels) } fn raster_3d(&self, scene: &effectcraft_render::three_d::adv::Scene) -> Option { diff --git a/crates/gpu/src/memcap.rs b/crates/gpu/src/memcap.rs new file mode 100644 index 000000000..f26b7f3e5 --- /dev/null +++ b/crates/gpu/src/memcap.rs @@ -0,0 +1,141 @@ +//! Memory back-pressure for frames in flight. +//! +//! wgpu destroys a texture or buffer we drop only once the submissions that used it have +//! completed, and pooled textures released while other frames' encoders are open stay pending +//! until then. With several 4K frames in flight, the frames record work faster than the GPU +//! retires it, and freed-but-not-destroyed memory runs the device out of memory (#879). Once the +//! allocator holds more than the cap, a frame submits its encoder and waits for the queue, which +//! lets wgpu free what was dropped. +//! +//! wgpu has no safe query for the adapter's memory size, so the default cap is conservative per +//! device type, and lower on a discrete card whose memory the OS reports without `unsafe` +//! (Linux's amdgpu driver, in sysfs: a 2–4 GB card ran out under the 8 GiB default and took +//! the system down, #1487); `EFFECTCRAFT_GPU_MEMCAP_MB` overrides it (0 = off). Backends +//! without an allocator report (Metal, WebGPU, GL) are never gated. + +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering::Relaxed}; + +/// Check the allocator only every this many gate calls (effect calls and layers): the report +/// lists every allocation, and between checks a frame adds at most a few textures. +const EVERY: u64 = 8; + +const GIB: u64 = 1 << 30; + +pub(crate) struct MemCap { + cap: u64, + calls: AtomicU64, + waits: AtomicU64, +} + +impl MemCap { + pub(crate) fn new(info: &wgpu::AdapterInfo) -> MemCap { + let env = std::env::var("EFFECTCRAFT_GPU_MEMCAP_MB").ok().and_then(|v| v.trim().parse::().ok()); + let cap = env.map_or_else(|| default_cap(info.device_type, vram(info)), |mb| mb << 20); + MemCap { cap, calls: AtomicU64::new(0), waits: AtomicU64::new(0) } + } + + /// The cap to check against now, or `None` (off, or not this call). + pub(crate) fn due(&self) -> Option { + (self.cap != 0 && self.calls.fetch_add(1, Relaxed).is_multiple_of(EVERY)).then_some(self.cap) + } + + pub(crate) fn waited(&self) { + let n = self.waits.fetch_add(1, Relaxed); + if n == 0 { + log::info!("gpu: allocator over the {} MiB memory cap, frames wait for the GPU to catch up", self.cap >> 20); + } + } +} + +/// Discrete cards: 8 GiB, under the 10–24 GiB of current cards with headroom for the +/// driver and other apps, and at most three quarters of the card's memory when it is known +/// (smaller cards elsewhere: set `EFFECTCRAFT_GPU_MEMCAP_MB`). Integrated and software +/// adapters share system memory: 2 GiB. +fn default_cap(t: wgpu::DeviceType, vram: Option) -> u64 { + match t { + wgpu::DeviceType::DiscreteGpu => vram.map_or(8 * GIB, |v| (v / 4).saturating_mul(3).min(8 * GIB)), + _ => 2 * GIB, + } +} + +/// The adapter's dedicated memory in bytes, where the OS reports it without `unsafe`. +fn vram(info: &wgpu::AdapterInfo) -> Option { + if !cfg!(target_os = "linux") { + return None; + } + let bytes = vram_in_sysfs(Path::new("/sys/class/drm"), info.vendor, info.device); + if let Some(b) = bytes { + log::info!("gpu {}: {} MiB of video memory (sysfs)", info.name, b >> 20); + } + bytes +} + +/// `mem_info_vram_total` (amdgpu) of the DRM card under `drm` whose PCI vendor and device ids +/// are `vendor` and `device`. +fn vram_in_sysfs(drm: &Path, vendor: u32, device: u32) -> Option { + let id = |path: &Path| std::fs::read_to_string(path).ok().and_then(|s| u32::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok()); + let mut cards: Vec<_> = std::fs::read_dir(drm) + .ok()? + .flatten() + .map(|e| e.path()) + .filter(|p| p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("card") && !n.contains('-'))) + .collect(); + cards.sort(); + cards.iter().find_map(|card| { + let dev = card.join("device"); + if id(&dev.join("vendor")) != Some(vendor) || id(&dev.join("device")) != Some(device) { + return None; + } + std::fs::read_to_string(dev.join("mem_info_vram_total")).ok()?.trim().parse::().ok().filter(|&b| b > 0) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_and_cadence() { + assert_eq!(default_cap(wgpu::DeviceType::DiscreteGpu, None), 8 * GIB); + assert_eq!(default_cap(wgpu::DeviceType::IntegratedGpu, None), 2 * GIB); + let m = MemCap { cap: GIB, calls: AtomicU64::new(0), waits: AtomicU64::new(0) }; + let due = (0..2 * EVERY).filter(|_| m.due().is_some()).count(); + assert_eq!(due, 2); + let off = MemCap { cap: 0, calls: AtomicU64::new(0), waits: AtomicU64::new(0) }; + assert!((0..EVERY).all(|_| off.due().is_none())); + } + + /// #1487: a 4 GB RX 550 gets a 3 GiB cap, not the 8 GiB that let it run out; a 16 GB card + /// keeps 8 GiB, and an integrated GPU's carve-out doesn't lower its shared-memory cap. + #[test] + fn known_video_memory_lowers_the_discrete_cap() { + assert_eq!(default_cap(wgpu::DeviceType::DiscreteGpu, Some(4 * GIB)), 3 * GIB); + assert_eq!(default_cap(wgpu::DeviceType::DiscreteGpu, Some(2 * GIB)), 3 * GIB / 2); + assert_eq!(default_cap(wgpu::DeviceType::DiscreteGpu, Some(16 * GIB)), 8 * GIB); + assert_eq!(default_cap(wgpu::DeviceType::IntegratedGpu, Some(GIB / 2)), 2 * GIB); + } + + #[test] + fn video_memory_is_read_from_the_matching_drm_card() { + let dir = std::env::temp_dir().join(format!("effectcraft-memcap-{}", std::process::id())); + let card = |name: &str, vendor: &str, device: &str, vram: Option<&str>| { + let dev = dir.join(name).join("device"); + std::fs::create_dir_all(&dev).unwrap(); + std::fs::write(dev.join("vendor"), vendor).unwrap(); + std::fs::write(dev.join("device"), device).unwrap(); + if let Some(v) = vram { + std::fs::write(dev.join("mem_info_vram_total"), v).unwrap(); + } + }; + // An integrated Intel GPU (no amdgpu file), the RX 550 and a connector entry. + card("card0", "0x8086\n", "0x9bc5\n", None); + card("card1", "0x1002\n", "0x699f\n", Some("4294967296\n")); + std::fs::create_dir_all(dir.join("card1-DP-1")).unwrap(); + assert_eq!(vram_in_sysfs(&dir, 0x1002, 0x699f), Some(4 * GIB)); + assert_eq!(vram_in_sysfs(&dir, 0x8086, 0x9bc5), None, "no mem_info_vram_total"); + assert_eq!(vram_in_sysfs(&dir, 0x1002, 0x73bf), None, "no such card"); + assert_eq!(vram_in_sysfs(&dir.join("missing"), 0x1002, 0x699f), None); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index e95412292..13055ffbf 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -199,6 +199,12 @@ fn draw_collapsed<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: & } fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage) -> Option<()> { + let out = draw_layer_on(e, r, ctx, layer, canvas); + e.mem_gate(); + out +} + +fn draw_layer_on(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage) -> Option<()> { if layer.switches.adjustment { if draw_adjustment(e, r, ctx, layer, canvas).is_some() { return Some(());