From d8ff1189dcd1177f30601d506f297e92daac983f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:05:38 +0200 Subject: [PATCH] GPU: the upload cache budget can be set per context and warns once when full, instead of evicting layer textures silently (#687) --- crates/gpu/src/context.rs | 29 ++++++++++++++++++++++++++--- crates/gpu/src/tests.rs | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 793c4d9ed..51221abc3 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -37,7 +37,7 @@ const EXT_ENTRIES: &[&str] = &["classic3d"]; /// 8/16 bpc are emulated by clamping and quantising, as on the CPU). pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float; -/// Uploaded layer buffers kept on the GPU (bytes). +/// Uploaded layer buffers kept on the GPU by default (bytes; [`GpuContext::set_upload_budget`]). const UPLOAD_BUDGET: usize = 1536 << 20; /// Working textures kept for reuse once their last image is dropped (bytes). @@ -296,6 +296,8 @@ pub struct GpuContext { dummy_tex: wgpu::TextureView, dummy_buf: wgpu::Buffer, uploads: Mutex, + upload_budget: std::sync::atomic::AtomicUsize, + upload_full_warned: std::sync::atomic::AtomicBool, pool: Arc>, /// CPU ↔ GPU traffic (diagnostics: `bench --gpu`). pub(crate) transfers: Transfers, @@ -641,6 +643,8 @@ impl GpuContext { dummy, dummy_buf, uploads: Mutex::new(Uploads::default()), + upload_budget: std::sync::atomic::AtomicUsize::new(UPLOAD_BUDGET), + upload_full_warned: Default::default(), pool: Default::default(), staging: Default::default(), transfers: Default::default(), @@ -869,6 +873,17 @@ impl GpuContext { (GpuImage { texture, width: w, height: h, _lease: Some(lease), levels: None }, was_reused) } + /// Bytes of uploaded layer textures the context keeps (default 1536 MB). An application that + /// knows the device's memory can raise it (static layers of a big frame upload once) or lower + /// it (a small GPU); the least recently used textures go first. + pub fn set_upload_budget(&self, bytes: usize) { + self.upload_budget.store(bytes.max(1 << 20), std::sync::atomic::Ordering::Relaxed); + } + + pub fn upload_budget(&self) -> usize { + self.upload_budget.load(std::sync::atomic::Ordering::Relaxed) + } + /// Uploads and readbacks so far (images; uploads of cached layers count once). pub fn transfer_stats(&self) -> TransferStats { use std::sync::atomic::Ordering::Relaxed; @@ -925,7 +940,8 @@ impl GpuContext { } u.bytes += bytes; // Drop textures of freed buffers, then the least recently used over budget. - if u.bytes > UPLOAD_BUDGET { + let budget = self.upload_budget(); + if u.bytes > budget { let dead: Vec = u.map.iter().filter(|(_, e)| e.buf.strong_count() == 0).map(|(k, _)| *k).collect(); for k in dead { if let Some(e) = u.map.remove(&k) { @@ -933,7 +949,14 @@ impl GpuContext { } } } - while u.bytes > UPLOAD_BUDGET { + if u.bytes > budget && !self.upload_full_warned.swap(true, std::sync::atomic::Ordering::Relaxed) { + log::warn!( + target: "effectcraft_gpu", + "GPU upload cache full ({} MB): evicting layer textures, which upload again when next used; a frame whose static layers exceed it re-uploads them every frame", + budget >> 20 + ); + } + while u.bytes > budget { let Some(k) = u.map.iter().min_by_key(|(_, e)| e.last_use).map(|(k, _)| *k) else { break }; if let Some(e) = u.map.remove(&k) { u.bytes -= e.bytes; diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index fcebcd44b..426899c76 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -942,3 +942,40 @@ fn texture_reuse_does_not_change_output() { assert!(off.is_some_and(|off| off.data == on.data), "texture reuse changed the frame"); check("texture reuse vs CPU", compare_at(&s, opts(), t), 0.0); } + +/// Static layer buffers (here adjustment-layer footprints) upload once while they fit the upload budget, and upload again every frame +/// when they do not (least recently used first); the frame is the same either way. +#[test] +fn upload_budget_bounds_the_layer_textures_kept() { + let Some(g) = gpu() else { return }; + let mut s = Scene::new(BitDepth::Bpc8); + for (i, c) in [[0.8, 0.2, 0.1], [0.1, 0.3, 0.9]].into_iter().enumerate() { + let mut l = s.solid(c, 200, 200); + set(&mut l, "transform/position", v3(60.0 + 30.0 * i as f64, 40.0)); + // Adjustment layers: their footprints are cached layer buffers the GPU uploads. + l.switches.adjustment = true; + s.effect(&mut l, "ec.color.levels", &[("gamma", n(1.1 + 0.2 * i as f64))]); + s.push(l); + } + let frame = |cache: &effectcraft_render::cache::LayerCache, k: u64| { + let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + r.accel = Some(g); + r.cache = Some(cache); + let before = g.ctx.transfer_stats().upload_bytes; + let img = g.render(&r, s.cid, Tick::from_seconds_f64(k as f64 / 30.0)).expect("the GPU renders the frame"); + (img.data, g.ctx.transfer_stats().upload_bytes - before) + }; + let fits = effectcraft_render::cache::LayerCache::new(64 << 20); + let (a, _) = frame(&fits, 0); + let (b, up) = frame(&fits, 1); + assert_eq!(up, 0, "static layers re-uploaded within the default budget"); + // A budget smaller than the two 640 KB footprints: each upload evicts the other. + let default = g.ctx.upload_budget(); + g.ctx.set_upload_budget(1 << 20); + let tight = effectcraft_render::cache::LayerCache::new(64 << 20); + let _ = frame(&tight, 0); + let (c, up) = frame(&tight, 1); + g.ctx.set_upload_budget(default); + assert!(up > 0, "over budget, the footprints must upload again"); + assert!(a == b && b == c, "the upload budget changed the frame"); +}