Files
effectcraft/crates/gpu/src/memcap.rs
T
JosephandFredApps 6572514eb1 GPU: size the memory cap from the card's video memory on Linux (fixes #1487) (#1706)
On Linux, the discrete-GPU memory cap now comes from the card's video memory: min(3 GiB, ¾ of amdgpu's `mem_info_vram_total`), so a 2 GB RX 550 gets 1.5 GiB. The card is matched by PCI address, falling back to the smallest card with the same ids.

Also folds in CodeRabbit follow-ups from #1856 (PatchMatch flags buffer limit preflight), #1490 (arbitrary-map index wrap), #1836 (round_away for |x| >= 2^23) and #3025 (saturating FrameCache byte accounting).

Fixes #1487

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-04 01:55:02 +01:00

329 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 and enough of that total is reclaimable (not held by the
//! upload cache, the texture pool, or live images), a frame submits its encoder and waits for its
//! own submission so wgpu frees what was dropped. Frames are not held back for memory that cannot
//! be reclaimed; a warning is logged once.
//!
//! wgpu has no safe query for the adapter's memory size (`max_buffer_size` is capped at `i32::MAX`
//! on Direct3D 12 and Linux/Mesa Vulkan), 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 of memory 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::{AtomicBool, AtomicU64, Ordering::Relaxed};
use std::time::Duration;
/// 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;
const MIB: u64 = 1 << 20;
/// Overrides the default cap: whole MiB, 0 turns the gate off.
const MEMCAP_ENV: &str = "EFFECTCRAFT_GPU_MEMCAP_MB";
pub(crate) struct MemCap {
cap: u64,
hysteresis: u64,
calls: AtomicU64,
waits: AtomicU64,
/// Reclaimable bytes remaining after the last wait: another wait fires only once reclaimable
/// memory grows by `hysteresis` above this floor (or drops below it first).
floor: AtomicU64,
/// Set after the first over-cap sample where non-reclaimable memory alone exceeds the cap.
warned: AtomicBool,
#[cfg(test)]
timeout_ns: AtomicU64,
}
impl MemCap {
pub(crate) fn new(info: &wgpu::AdapterInfo) -> MemCap {
let env = std::env::var(MEMCAP_ENV).ok();
Self::from_env(env.as_deref(), info.device_type, vram(info))
}
fn from_env(var: Option<&str>, device_type: wgpu::DeviceType, vram: Option<u64>) -> MemCap {
let cap = cap_from_env(var, device_type, vram);
MemCap {
cap,
hysteresis: default_hysteresis(cap),
calls: AtomicU64::new(0),
waits: AtomicU64::new(0),
floor: AtomicU64::new(0),
warned: AtomicBool::new(false),
#[cfg(test)]
timeout_ns: AtomicU64::new(crate::readback::TIMEOUT.as_nanos() as u64),
}
}
/// The same cap with fresh counters (a test context sharing another's device).
#[cfg(test)]
pub(crate) fn fresh(&self) -> MemCap {
MemCap {
cap: self.cap,
hysteresis: self.hysteresis,
calls: AtomicU64::new(0),
waits: AtomicU64::new(0),
floor: AtomicU64::new(0),
warned: AtomicBool::new(false),
timeout_ns: AtomicU64::new(self.timeout_ns.load(Relaxed)),
}
}
pub(crate) fn cap(&self) -> u64 {
self.cap
}
/// Whether to sample the allocator on this call.
pub(crate) fn due(&self) -> bool {
self.cap != 0 && self.calls.fetch_add(1, Relaxed).is_multiple_of(EVERY)
}
/// Whether `allocated` bytes in the allocator with `kept` non-reclaimable bytes (upload
/// cache, pool free list, live frames) should trigger a submit and wait.
pub(crate) fn should_wait(&self, allocated: u64, kept: u64) -> bool {
if self.cap == 0 || allocated <= self.cap {
self.floor.store(0, Relaxed);
return false;
}
if kept > self.cap && !self.warned.swap(true, Relaxed) {
log::warn!(
target: "effectcraft_gpu",
"gpu: {} MiB held by live frames, the upload cache and the texture pool is over the {} MiB memory cap: waiting cannot free it, so frames are not held back for it ({MEMCAP_ENV} sets the cap)",
kept >> 20,
self.cap >> 20,
);
}
let reclaimable = allocated.saturating_sub(kept);
let floor = self.floor.load(Relaxed);
if reclaimable < floor {
self.floor.store(reclaimable, Relaxed);
return false;
}
reclaimable >= floor.saturating_add(self.hysteresis)
}
pub(crate) fn record_wait(&self, allocated_after: u64, kept_after: u64) {
self.floor.store(allocated_after.saturating_sub(kept_after), Relaxed);
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);
}
}
pub(crate) fn wait_timeout(&self) -> Duration {
#[cfg(test)]
{
Duration::from_nanos(self.timeout_ns.load(Relaxed))
}
#[cfg(not(test))]
{
crate::readback::TIMEOUT
}
}
#[cfg(test)]
pub(crate) fn waits(&self) -> u64 {
self.waits.load(Relaxed)
}
#[cfg(test)]
pub(crate) fn warned(&self) -> bool {
self.warned.load(Relaxed)
}
#[cfg(test)]
pub(crate) fn set_wait_timeout(&self, timeout: Duration) {
self.timeout_ns.store(timeout.as_nanos() as u64, Relaxed);
}
}
/// The cap from the `EFFECTCRAFT_GPU_MEMCAP_MB` value: whole MiB (0 turns the gate off), or the
/// default for `device_type` when unset, or (with a warning) when not a number or too large.
fn cap_from_env(var: Option<&str>, device_type: wgpu::DeviceType, vram: Option<u64>) -> u64 {
let Some(v) = var else { return default_cap(device_type, vram) };
match v.trim().parse::<u64>().ok().and_then(|mb| mb.checked_mul(MIB)) {
Some(cap) => cap,
None => {
let cap = default_cap(device_type, vram);
log::warn!(target: "effectcraft_gpu", "{MEMCAP_ENV}={v:?} is not a whole number of MiB that fits in 64 bits: using {} MiB", cap / MIB);
cap
}
}
}
/// Discrete cards: 3 GiB, low enough to fire on 4–8 GB cards with headroom for the driver and
/// desktop while leaving ~1 GiB above the default 1.5 GiB upload cache + 256 MB pool (larger
/// cards can raise it with `EFFECTCRAFT_GPU_MEMCAP_MB`), and at most three quarters of the card's
/// memory when it is known (`vram`), so a 2 GB card gets 1.5 GiB (#1487). Integrated and software
/// adapters share system memory: 2 GiB, whatever their carve-out.
fn default_cap(t: wgpu::DeviceType, vram: Option<u64>) -> u64 {
match t {
wgpu::DeviceType::DiscreteGpu => vram.map_or(3 * GIB, |v| (v / 4).saturating_mul(3).min(3 * 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") || info.device_type != wgpu::DeviceType::DiscreteGpu {
return None;
}
let bytes = vram_in_sysfs(Path::new("/sys/class/drm"), info.vendor, info.device, &info.device_pci_bus_id);
if let Some(b) = bytes {
log::info!(target: "effectcraft_gpu", "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`. Connector entries (`card1-DP-1`) are skipped. Variants of one model share
/// those ids (the RX 550 ships with 2 and 4 GB), so the card at PCI address `bus` (`uevent`'s
/// `PCI_SLOT_NAME`, wgpu's `device_pci_bus_id`) wins; without one, the smallest matching card.
fn vram_in_sysfs(drm: &Path, vendor: u32, device: u32, bus: &str) -> 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 slot = |dev: &Path| {
std::fs::read_to_string(dev.join("uevent")).ok()?.lines().find_map(|l| l.strip_prefix("PCI_SLOT_NAME=").map(|s| s.trim().to_ascii_lowercase()))
};
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();
let found: Vec<(Option<String>, u64)> = cards
.iter()
.filter_map(|card| {
let dev = card.join("device");
if id(&dev.join("vendor")) != Some(vendor) || id(&dev.join("device")) != Some(device) {
return None;
}
let bytes = std::fs::read_to_string(dev.join("mem_info_vram_total")).ok()?.trim().parse::<u64>().ok().filter(|&b| b > 0)?;
Some((slot(&dev), bytes))
})
.collect();
let bus = bus.trim().to_ascii_lowercase();
found.iter().find(|(s, _)| !bus.is_empty() && s.as_deref() == Some(bus.as_str())).map(|&(_, b)| b).or_else(|| found.iter().map(|&(_, b)| b).min())
}
/// Minimum reclaimable bytes before a wait is worth draining an encoder (1/8 of the cap,
/// clamped between 1 MiB and 256 MiB, or 1/4 of caps smaller than 4 MiB).
fn default_hysteresis(cap: u64) -> u64 {
if cap == 0 { 0 } else { (cap / 8).clamp(MIB.min(cap / 4), 256 * MIB) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_and_cadence() {
assert_eq!(default_cap(wgpu::DeviceType::DiscreteGpu, None), 3 * GIB);
assert_eq!(default_cap(wgpu::DeviceType::IntegratedGpu, None), 2 * GIB);
let m = MemCap::from_env(None, wgpu::DeviceType::IntegratedGpu, None);
assert_eq!(m.cap(), 2 * GIB);
let due = (0..2 * EVERY).filter(|_| m.due()).count();
assert_eq!(due, 2);
let off = MemCap::from_env(Some("0"), wgpu::DeviceType::DiscreteGpu, None);
assert_eq!(off.cap(), 0);
assert!((0..EVERY).all(|_| !off.due()));
}
#[test]
fn override_is_whole_mib_or_the_default() {
let cap = |v| MemCap::from_env(Some(v), wgpu::DeviceType::DiscreteGpu, None).cap();
assert_eq!(cap("2048"), 2 * GIB);
assert_eq!(cap(" 8 "), 8 * MIB);
assert_eq!(cap("17592186044415"), 17592186044415 * MIB, "the largest value that fits");
for bad in ["", "lots", "-1", "1.5", "17592186044416", "99999999999999999999"] {
assert_eq!(cap(bad), 3 * GIB, "{bad:?} falls back to the default");
}
}
#[test]
fn hysteresis_skips_non_reclaimable_and_unfreed_memory() {
let m = MemCap::from_env(Some("2048"), wgpu::DeviceType::IntegratedGpu, None);
// Upload cache + pool + live frames (2.15 GiB) sit over the 2 GiB cap with only 50 MiB
// reclaimable: waiting would free almost nothing, so the gate must stay quiet.
let kept = 2150 * MIB;
assert!(!m.should_wait(kept + 50 * MIB, kept));
assert!(!m.should_wait(kept + 200 * MIB, kept));
// Once 256 MiB of dropped textures pile up, the gate fires.
assert!(m.should_wait(kept + 256 * MIB, kept));
// If a wait freed nothing (e.g. held by another thread's open encoder or timed out),
// the floor prevents firing again until another 256 MiB of reclaimable memory accumulates.
m.record_wait(kept + 256 * MIB, kept);
assert!(!m.should_wait(kept + 256 * MIB, kept));
assert!(!m.should_wait(kept + 400 * MIB, kept));
assert!(m.should_wait(kept + 512 * MIB, kept));
// Once the other thread finishes and reclaimable memory drops, the floor drops with it.
assert!(!m.should_wait(kept + 16 * MIB, kept));
assert!(m.should_wait(kept + (16 + 256) * MIB, kept));
}
#[test]
fn kept_memory_over_the_cap_warns_once_and_never_waits() {
let m = MemCap::from_env(Some("8"), wgpu::DeviceType::IntegratedGpu, None);
// Cap 8 MiB: kept alone over the cap with reclaimable under the hysteresis → no wait, warn once.
assert!(!m.should_wait(20 * MIB, 20 * MIB));
assert!(m.warned());
assert!(!m.should_wait(20 * MIB, 20 * MIB));
assert!(!m.should_wait(20 * MIB, 19 * MIB + MIB / 2));
assert!(m.should_wait(20 * MIB, 4 * MIB), "16 MiB reclaimable is over the hysteresis");
}
/// #1487: a 2–4 GB card gets three quarters of its memory instead of the 3 GiB default; a
/// larger card keeps the default, and an integrated GPU's carve-out doesn't lower its cap.
#[test]
fn known_video_memory_lowers_the_discrete_cap() {
let d = wgpu::DeviceType::DiscreteGpu;
assert_eq!(default_cap(d, Some(2 * GIB)), 3 * GIB / 2);
assert_eq!(default_cap(d, Some(4 * GIB)), 3 * GIB);
assert_eq!(default_cap(d, Some(16 * GIB)), 3 * GIB, "never above the default");
assert_eq!(default_cap(wgpu::DeviceType::IntegratedGpu, Some(GIB / 2)), 2 * GIB);
assert_eq!(cap_from_env(None, d, Some(2 * GIB)), 3 * GIB / 2);
assert_eq!(cap_from_env(Some("lots"), d, Some(2 * GIB)), 3 * GIB / 2, "a bad override falls back to it");
assert_eq!(cap_from_env(Some("4096"), d, Some(2 * GIB)), 4 * GIB, "the override still wins");
}
#[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);
// A 2 GB RX 550 next to the 4 GB one: same ids, told apart by PCI address.
card("card2", "0x1002\n", "0x699f\n", Some("2147483648\n"));
std::fs::write(dir.join("card1/device/uevent"), "DRIVER=amdgpu\nPCI_SLOT_NAME=0000:01:00.0\n").unwrap();
std::fs::write(dir.join("card2/device/uevent"), "DRIVER=amdgpu\nPCI_SLOT_NAME=0000:03:00.0\n").unwrap();
assert_eq!(vram_in_sysfs(&dir, 0x1002, 0x699f, "0000:01:00.0"), Some(4 * GIB));
assert_eq!(vram_in_sysfs(&dir, 0x1002, 0x699f, "0000:03:00.0"), Some(2 * GIB));
assert_eq!(vram_in_sysfs(&dir, 0x1002, 0x699f, ""), Some(2 * GIB), "unknown address: the smaller card");
assert_eq!(vram_in_sysfs(&dir, 0x1002, 0x699f, "0000:09:00.0"), Some(2 * GIB), "no such address: the smaller card");
let _ = std::fs::remove_dir_all(&dir);
}
}