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.
This commit is contained in:
Matej Kozlovský
2026-10-16 14:21:20 +02:00
parent 13a49c943f
commit 0a884fb65e
5 changed files with 145 additions and 18 deletions
+3
View File
@@ -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;
+55 -18
View File
@@ -117,8 +117,16 @@ 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>,
}
/// 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::<usize>() > 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<wgpu::Texture> {
@@ -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 }
+4
View File
@@ -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();
+80
View File
@@ -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) = (&params, &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");
}
+3
View File
@@ -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
}