Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions crates/gpu/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ pub(crate) struct Pool {
/// working texture of another `Enc` before it is gone.
owned: HashMap<u64, HashMap<(u32, u32), Vec<wgpu::Texture>>>,
next_owner: u64,
/// Owners kept between [`Enc::lent`] calls, each lent to one `Enc` at a time.
idle: Vec<u64>,
}

/// In-`Enc` texture reuse ([`Pool::owned`]); tests turn it off to compare.
Expand Down Expand Up @@ -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::<usize>() > POOL_BUDGET
{
m.clear();
}
self.idle.push(owner);
}

fn take_owned(&mut self, owner: u64, w: u32, h: u32) -> Option<wgpu::Texture> {
let t = self.owned.get_mut(&owner)?.get_mut(&(w, h))?.pop()?;
self.hits += 1;
Expand Down Expand Up @@ -298,6 +316,8 @@ pub struct GpuContext {
uploads: Mutex<Uploads>,
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<Mutex<Pool>>,
/// CPU ↔ GPU traffic (diagnostics: `bench --gpu`).
pub(crate) transfers: Transfers,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<'_> {
Expand All @@ -1000,15 +1038,30 @@ 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);
}
}
}
}

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
Expand Down
2 changes: 2 additions & 0 deletions crates/gpu/src/effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion crates/gpu/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ mod fx_tone;
mod fx_transition;
mod fx_vr;
mod fx_warp;
mod memcap;
mod ops;
mod particles;
mod readback;
Expand Down Expand Up @@ -341,7 +342,7 @@ impl Accelerator for Gpu {

fn effects(&self, chain: &[FxStep], buf: &Buf, levels: Option<f32>) -> Option<Buf> {
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<effectcraft_render::three_d::adv::Target> {
Expand Down
141 changes: 141 additions & 0 deletions crates/gpu/src/memcap.rs
Original file line number Diff line number Diff line change
@@ -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::<u64>().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<u64> {
(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>) -> 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<u64> {
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<u64> {
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::<u64>().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);
}
}
6 changes: 6 additions & 0 deletions crates/gpu/src/walk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
Expand Down