GPU: the upload cache budget can be set per context and warns once when full, instead of evicting layer textures silently (#687)

This commit is contained in:
Matej Kozlovský committed 2026-10-12 09:47:28 +02:00
1 parent 9c5c187292
commit d8ff1189dc
2 files changed
+63 -3

No files matched your search

+26 -3
View File
@@ -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<Uploads>,
upload_budget: std::sync::atomic::AtomicUsize,
upload_full_warned: std::sync::atomic::AtomicBool,
pool: Arc<Mutex<Pool>>,
/// 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<usize> = 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;
+37
View File
@@ -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");
}