mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-07 13:23:37 +00:00
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:
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user