From 13a49c943f59709e7705d7167512d68a4179b8d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:49:23 +0200 Subject: [PATCH 1/3] GPU: free effect working textures after each call instead of at frame end A GPU frame keeps its encoder open while the CPU renderer runs layers with GPU effects (Gpu::effects -> run_chain). Each call's Enc handed its textures back to the shared pool, where they waited for every open encoder, so all effect intermediates of a frame stayed allocated until the frame ended (17.7 GB by layer 17 of a heavy 4K comp, then OOM). Effect calls now borrow a pool owner (Enc::lent) that is given back once the call's work is submitted or discarded; its kept textures are trimmed to the pool budget, so the rest are freed after each call. --- crates/gpu/src/context.rs | 39 +++++++++++++++++++++++++++++++++++++-- crates/gpu/src/lib.rs | 2 +- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 51221abc37..d0ac4243ff 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; @@ -991,6 +1009,8 @@ 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 Drop for Enc<'_> { @@ -1000,7 +1020,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 +1032,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/lib.rs b/crates/gpu/src/lib.rs index 252145c14c..a03c25354f 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -341,7 +341,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 { From 23da45ddc6bd131a06fde631b9031f9de3cbfa44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= Date: Thu, 15 Oct 2026 10:19:20 +0200 Subject: [PATCH 2/3] gpu: memory back-pressure for frames in flight Once the wgpu allocator holds more than a cap (8 GiB on discrete GPUs, 2 GiB otherwise; EFFECTCRAFT_GPU_MEMCAP_MB overrides, 0 = off), a frame submits its encoder and waits for the queue so wgpu destroys dropped textures and pending pool textures free up. Checked every 8 effect calls / layers. Fixes out-of-memory errors at 3-4 frames in flight on 4K comps. --- crates/gpu/src/context.rs | 18 ++++++++++ crates/gpu/src/effects.rs | 2 ++ crates/gpu/src/lib.rs | 1 + crates/gpu/src/memcap.rs | 71 +++++++++++++++++++++++++++++++++++++++ crates/gpu/src/walk.rs | 6 ++++ 5 files changed, 98 insertions(+) create mode 100644 crates/gpu/src/memcap.rs diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index d0ac4243ff..4e55e48c1a 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -316,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, @@ -663,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(), @@ -1013,6 +1016,21 @@ pub(crate) struct Enc<'g> { 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<'_> { fn drop(&mut self) { // Unsubmitted work is discarded, so nothing recorded will read pooled textures. diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 42d2bab711..6f00c29dcb 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 a03c25354f..fc3f07b7e3 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; diff --git a/crates/gpu/src/memcap.rs b/crates/gpu/src/memcap.rs new file mode 100644 index 0000000000..58b5d95c07 --- /dev/null +++ b/crates/gpu/src/memcap.rs @@ -0,0 +1,71 @@ +//! 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; `EFFECTCRAFT_GPU_MEMCAP_MB` overrides it (0 = off). Backends without an +//! allocator report (Metal, WebGPU, GL) are never gated. + +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()); + MemCap { cap: env.map_or_else(|| default_cap(info.device_type), |mb| mb << 20), 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 (smaller cards: set `EFFECTCRAFT_GPU_MEMCAP_MB`). Integrated and +/// software adapters share system memory: 2 GiB. +fn default_cap(t: wgpu::DeviceType) -> u64 { + match t { + wgpu::DeviceType::DiscreteGpu => 8 * GIB, + _ => 2 * GIB, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_and_cadence() { + assert_eq!(default_cap(wgpu::DeviceType::DiscreteGpu), 8 * GIB); + assert_eq!(default_cap(wgpu::DeviceType::IntegratedGpu), 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())); + } +} diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index e954122927..13055ffbf3 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(()); From d02f000a19f80f77f1031e9d4afafe90a530caec Mon Sep 17 00:00:00 2001 From: 00xJ0 <56056764+00xJ0@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:09:55 -0700 Subject: [PATCH 3/3] #1487: size the GPU memory cap from the card's video memory where Linux reports it #1550's default cap is 8 GiB for every discrete card, above all of a 2-4 GB card such as the RX 550 in #1487, which ran out of memory during export and took the system down. On Linux the amdgpu driver publishes the card's memory in sysfs (mem_info_vram_total), readable without unsafe: match the DRM card by PCI vendor and device id and cap discrete cards at min(8 GiB, 3/4 of it). Integrated GPUs keep 2 GiB; EFFECTCRAFT_GPU_MEMCAP_MB still overrides. Co-Authored-By: Claude --- crates/gpu/src/memcap.rs | 88 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 9 deletions(-) diff --git a/crates/gpu/src/memcap.rs b/crates/gpu/src/memcap.rs index 58b5d95c07..f26b7f3e54 100644 --- a/crates/gpu/src/memcap.rs +++ b/crates/gpu/src/memcap.rs @@ -8,9 +8,12 @@ //! 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; `EFFECTCRAFT_GPU_MEMCAP_MB` overrides it (0 = off). Backends without an -//! allocator report (Metal, WebGPU, GL) are never gated. +//! 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 @@ -28,7 +31,8 @@ pub(crate) struct MemCap { 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()); - MemCap { cap: env.map_or_else(|| default_cap(info.device_type), |mb| mb << 20), calls: AtomicU64::new(0), waits: AtomicU64::new(0) } + 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). @@ -45,27 +49,93 @@ impl MemCap { } /// Discrete cards: 8 GiB, under the 10–24 GiB of current cards with headroom for the -/// driver and other apps (smaller cards: set `EFFECTCRAFT_GPU_MEMCAP_MB`). Integrated and -/// software adapters share system memory: 2 GiB. -fn default_cap(t: wgpu::DeviceType) -> u64 { +/// 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 => 8 * GIB, + 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), 8 * GIB); - assert_eq!(default_cap(wgpu::DeviceType::IntegratedGpu), 2 * GIB); + 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); + } }