From 0a884fb65ed94b34b73c3c1f0182381e1e32b312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= Date: Fri, 16 Oct 2026 13:16:13 +0200 Subject: [PATCH] GPU: one pool budget for idle effect calls, trimmed at the end of a render Review of #1523: each idle lent owner kept up to POOL_BUDGET of textures of its own, outside the shared accounting, and there were as many idle owners as effect calls ever ran at once (16 frame threads: ~12 GB after a 4K render). Giving a lent owner back now moves its kept textures to the shared free list, so one POOL_BUDGET bounds what the free list and every finished effect call keep. They skip Pool::release's wait for open encoders: only the lent Enc recorded work on them, and that work is submitted or discarded. Each lend is a fresh owner id, so a lease that outlives its call (an image handed to another encoder) is released through Pool::release; this does not rely on the result being read back. Accelerator::render_end (called when an export ends) drops the kept textures. --- crates/export/src/lib.rs | 3 ++ crates/gpu/src/context.rs | 73 ++++++++++++++++++++++++++--------- crates/gpu/src/lib.rs | 4 ++ crates/gpu/src/tests.rs | 80 +++++++++++++++++++++++++++++++++++++++ crates/render/src/lib.rs | 3 ++ 5 files changed, 145 insertions(+), 18 deletions(-) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index eda979958..f07661afa 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -175,6 +175,9 @@ pub async fn export_async(job: &Job<'_>, progress: &mut dyn FnMut(&Progress) -> let t0 = Instant::now(); let cx = Cx::new(job); let mut r = export_with(&cx, progress, t0).await; + if let Some(a) = job.accel { + a.render_end(); + } if matches!(r, Err(ExportError::Cancelled)) { cx.remove_cancelled_outputs()?; return r; diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index d0ac4243f..e18bd2eed 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -117,8 +117,16 @@ 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, +} + +/// Tests lower [`POOL_BUDGET`] (bytes; 0 = the default) to fill it with small textures. +pub(crate) static POOL_BUDGET_OVERRIDE: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +fn pool_budget() -> usize { + match POOL_BUDGET_OVERRIDE.load(std::sync::atomic::Ordering::Relaxed) { + 0 => POOL_BUDGET, + b => b, + } } /// In-`Enc` texture reuse ([`Pool::owned`]); tests turn it off to compare. @@ -165,7 +173,7 @@ impl Pool { fn make_free(&mut self, t: wgpu::Texture) { let b = tex_bytes(&t); - if self.bytes + b > POOL_BUDGET { + if self.bytes.saturating_add(b) > pool_budget() { return; } self.bytes += b; @@ -217,20 +225,35 @@ impl Pool { self.release(t); } - /// An owner for [`Enc::lent`]: an idle one with its kept textures, or a new one. + /// An owner for [`Enc::lent`]. It is a fresh id each time, so a lease of an earlier lent + /// `Enc` that drops later (an image that outlived its call) finds no owner and goes + /// through [`Pool::release`], which waits for every open encoder. fn lend(&mut self) -> u64 { - self.idle.pop().unwrap_or_else(|| self.register()) + 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. + /// [`Enc::lent`] is done with `owner` (its work submitted or discarded): its kept textures + /// go to the shared free list at once, under the one [`POOL_BUDGET`] that also bounds what + /// every other idle call keeps, so 16 frame threads keep at most `POOL_BUDGET` between + /// them, not 16 × `POOL_BUDGET`. They skip [`Pool::release`]'s wait for open encoders: a + /// kept texture's lease has dropped and only this `Enc` ever recorded work on it, which is + /// already submitted or discarded, so later work cannot overtake it on the queue. This + /// does not rely on the result being read back: an image handed to another encoder keeps + /// its lease, and when that lease drops after this call it is released through + /// [`Pool::release`] (the owner is gone). 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(); + if let Some(m) = self.owned.remove(&owner) { + for t in m.into_values().flatten() { + self.make_free(t); + } } - self.idle.push(owner); + } + + /// Bytes of working textures kept for reuse: the free list, those waiting for open + /// encoders and those kept by live `Enc`s. + fn retained(&self) -> usize { + let owned = self.owned.values().flat_map(|m| m.values().flatten()); + self.released.iter().map(|(t, _)| t).chain(owned).map(tex_bytes).fold(self.bytes, usize::saturating_add) } fn take_owned(&mut self, owner: u64, w: u32, h: u32) -> Option { @@ -914,6 +937,19 @@ impl GpuContext { } } + /// Bytes of working textures the pool keeps for reuse (see [`GpuContext::trim_pool`]). + pub fn pool_retained_bytes(&self) -> usize { + self.pool.lock().map(|p| p.retained()).unwrap_or_default() + } + + /// Drop the working textures kept for reuse (a render is done). Textures still waiting for + /// an open encoder and those of live `Enc`s are unaffected. + pub fn trim_pool(&self) { + if let Ok(mut p) = self.pool.lock() { + p.clear(); + } + } + /// Texture pool counters: (working textures reused, newly allocated). pub fn pool_stats(&self) -> (u64, u64) { self.pool.lock().map(|p| (p.hits, p.misses)).unwrap_or_default() @@ -1035,12 +1071,13 @@ impl<'g> Enc<'g> { 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. + /// An `Enc` for self-contained work (an effect chain): once it is dropped, its working + /// textures can be reused at once ([`Pool::give_back`]). Its images stay with it, except a + /// result handed on (e.g. to the upload cache), whose lease then releases through the + /// guarded [`Pool::release`]. 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 } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index a03c25354..8255e8190 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -363,6 +363,10 @@ impl Accelerator for Gpu { Some(&self.auto) } + fn render_end(&self) { + self.ctx.trim_pool(); + } + fn frame_begin(&self) { if let Some(d) = self.ctx.deferred() { d.frame_begin(); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 696402d6a..f1647be7d 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -1044,3 +1044,83 @@ fn static_effect_layers_draw_from_the_cache() { assert!(frames[1] == frames[2]); check("static effect layer vs CPU", compare_at(&s, opts(), t(2)), 0.0); } + +/// Sets [`crate::context::POOL_BUDGET_OVERRIDE`] and restores the default when dropped. +struct PoolBudget; + +impl PoolBudget { + fn set(bytes: usize) -> PoolBudget { + crate::context::POOL_BUDGET_OVERRIDE.store(bytes, std::sync::atomic::Ordering::Relaxed); + PoolBudget + } +} + +impl Drop for PoolBudget { + fn drop(&mut self) { + crate::context::POOL_BUDGET_OVERRIDE.store(0, std::sync::atomic::Ordering::Relaxed); + } +} + +/// Review of #1523: every idle effect-call owner kept up to the pool budget of its own, +/// outside the shared accounting, and there are as many as calls ever ran at once (16 frame +/// threads: ~12 GB after a 4K render). The pool keeps one budget for all of them, and the end +/// of a render drops it. +#[test] +fn idle_effect_calls_share_one_pool_budget() { + let Some(g) = gpu() else { return }; + let ctx = g.context(); + ctx.clear_uploads(); + let budget = 8 << 20; + let _b = PoolBudget::set(budget); + // 16 effect calls in flight at once, each with three 1 MB working textures. + let mut encs: Vec<_> = (0..16).map(|_| crate::context::Enc::lent(ctx)).collect(); + for e in &mut encs { + let imgs: Vec<_> = (0..3).map(|_| e.image(256, 256)).collect(); + drop(imgs); + } + drop(encs); + let kept = ctx.pool_retained_bytes(); + assert!(kept <= budget, "idle effect calls keep {} MB, budget {} MB", kept >> 20, budget >> 20); + // A later call reuses them instead of allocating. + let (hits, _) = ctx.pool_stats(); + drop(crate::context::Enc::lent(ctx).image(256, 256)); + assert_eq!(ctx.pool_stats().0, hits + 1, "a later effect call reuses a kept texture"); + effectcraft_render::Accelerator::render_end(g); + assert_eq!(ctx.pool_retained_bytes(), 0, "the end of a render drops the kept textures"); +} + +/// The same through [`Gpu::effects`] from 16 threads at once, as an export's frame threads. +#[test] +fn effect_calls_on_16_threads_keep_one_pool_budget() { + let Some(g) = gpu() else { return }; + if on_gl() { + return; // one context lock per adapter on wgpu's GL backend (see hold_gpu_lock) + } + let ctx = g.context(); + ctx.clear_uploads(); + let budget = 8 << 20; + let _b = PoolBudget::set(budget); + let spec = effectcraft_effects::find("ec.blur.gaussian").unwrap(); + let size = [240.0, 240.0]; + let mut params = + effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() }; + params.values.insert("blurriness".into(), Value::Scalar(6.0)); + let buf = effectcraft_effects::Buf { img: pattern(3, 240, 240), offset: [0.0, 0.0], scale: 1.0 }; + let go = std::sync::Barrier::new(16); + std::thread::scope(|s| { + for i in 0..16 { + let (params, buf, go) = (¶ms, &buf, &go); + s.spawn(move || { + go.wait(); + for _ in 0..3 { + let fx = effectcraft_effects::EffectCtx { params, time: 0.0, layer_size: size, seed: i, adjustment: false, env: Default::default() }; + effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: fx }], buf, None).expect("the GPU runs the effect"); + } + }); + } + }); + let kept = ctx.pool_retained_bytes(); + assert!(kept <= budget, "16 threads of effect calls keep {} MB, budget {} MB", kept >> 20, budget >> 20); + effectcraft_render::Accelerator::render_end(g); + assert_eq!(ctx.pool_retained_bytes(), 0, "the end of a render drops the kept textures"); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 41036fe6b..c926f38a8 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -465,6 +465,9 @@ pub trait Accelerator: Send + Sync { /// results, `pass_begin` clears the miss. Synchronous accelerators never miss. fn frame_begin(&self) {} fn pass_begin(&self) {} + /// A render of many frames (an export) is done: drop what is kept only to speed up the + /// next frame (pooled working textures). + fn render_end(&self) {} fn pass_missed(&self) -> bool { false }