mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-10 04:21:25 +00:00
* GPU: free effect working textures after each call instead of at frame end A GPU frame keeps its encoder open while the CPU renderer runs layers with GPU effects (Gpu::effects -> run_chain). Each call's Enc handed its textures back to the shared pool, where they waited for every open encoder, so all effect intermediates of a frame stayed allocated until the frame ended (17.7 GB by layer 17 of a heavy 4K comp, then OOM). Effect calls now borrow a pool owner (Enc::lent) that is given back once the call's work is submitted or discarded; its kept textures are trimmed to the pool budget, so the rest are freed after each call. * 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. * GPU: evict the pool's oldest free textures, guard escaped leases Review of #1523 (follow-up to 0a884fb6): - Pool::make_free rejected a texture when the free list was full and never evicted, so once sizes that no longer occur (per-frame blur bounds, padded layer sizes) filled the budget, every later free was a drop and every take an allocation. It now evicts the least recently freed textures until the new one fits. - A lease read by another Enc (dispatch, copy, readback, display) or handed to a cache is marked escaped and always released through the guarded Pool::release, so a lent call can no longer reuse a texture that a still open encoder reads. - Docs: POOL_BUDGET caps the free list only; textures waiting for open encoders and those of live Encs are outside it. - export_async calls render_end from a drop guard, so the pool is also trimmed when an export panics or its future is dropped. - Tests use a private context with its own pool budget instead of a global override; new tests for stale sizes and an image read by an open encoder. * GPU: evict only stale pool sizes, count evicted textures until the GPU frees them Evicting the least recently freed texture for every newcomer thrashed when a frame frees more than the budget in a repeating order (blur/glow intermediates): each free evicted the size the next call takes first, so misses came back to the old level and evict + reallocate raised the peak. The free list now evicts only sizes nobody asked for in the last 2 output frames (Accelerator::frame_end, called by the export per frame); otherwise the incoming texture is dropped and the kept set keeps hitting. Evicted bytes count against the budget until the work submitted so far is done, so evictions run at most one budget ahead of the GPU. * GPU: lower the shared pool budget to 256 MB On 4K RTX 3090 runs 256 MB matches the old code's peak reserved on d_blurglow (9204 MB) and is 256-512 MB above it on c_effects (6592 vs 6080 MB), while staying faster. * GPU: end frames on the multi-frame render paths, route recording through Enc::record Only the export pipeline called frame_end, so off export (preview, remote frames) the pool's clock stood still, no size went stale and a full free list dropped every newcomer. passes::comp_frame (exports), remote frames and the preview workers now end each frame; test without pool_frame_end. Docs: the frame clock is coarse with many frames in flight; dying only throttles eviction, approximately; no cache path escapes images. Enc::record is the single way to get the encoder outside context.rs and marks the images it names (Enc::uses). * GPU: wait for the empty submission while evicted pool textures still count stale_sizes_do_not_starve_a_steady_size failed with 0 hits and 20 misses on hardware adapters: pool_frame_end submitted an empty batch and then only did a non-blocking Poll, and the queue's work-done callback did not run before the next frame, so Pool::dying never drained and make_free stopped evicting stale sizes. While dying is non-zero, pool_frame_end now waits on that empty submission for up to the readback timeout (the pool lock is already released). A wait timeout just moves on and does not retire the context or its readbacks; other poll errors still retire them. Without dying textures it keeps the cheap Poll. Enc::crop records through Enc::record(&[src, &dst]) so both pooled images are registered like on every other recording path (review). * GPU: do not repeat the pool frame-end wait until evicted textures drain A wait that frees none of the evicted bytes (timeout, or a WebGPU poll that does not block) marks the pool stalled; later frames only poll until dying drops. Test covers the no-repeat and partial-progress cases; dying decrement saturates. * Remote frames: end a pending frame that a new render replaces A render arriving while a multi-pass frame waits for readbacks dropped that frame without `frame_end`, so the GPU pool's clock skipped it. Errors return before `frame_begin`. * Render: end the frame even when a comp frame panics `passes::comp_frame` called `frame_end` only after a normal return, so a frame that panicked (or whose future was dropped between passes) never aged the GPU pool. * GPU pool: log the reuse rate at render end; test reuse across effect calls and frames Log cumulative hits/misses (and reuse %) once per render before trimming. Tests cover reuse within an open frame across effect calls, and across repeated frames. --------- Co-authored-by: Matej Kozlovský <MatejKozlovsky@users.noreply.github.com> Co-authored-by: bflatastic <bombayapirat@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
119 lines
4.7 KiB
Rust
119 lines
4.7 KiB
Rust
//! Rendering with deferred readbacks from loops that are not frame servers (the browser's
|
|
//! job workers: Render Queue renders and analyses).
|
|
//!
|
|
//! A frame worker renders one frame per request and resumes it pass by pass
|
|
//! (`FrameServer::resume`). Jobs render many frames from a loop; written as `async` functions,
|
|
//! the loop awaits [`in_passes`] for each frame: with an accelerator whose readbacks are
|
|
//! deferred ([`Accelerator::miss_gate`]), the render runs again after every pass that missed,
|
|
//! awaiting the device in between ([`Accelerator::settle`]), until a pass completes; with any
|
|
//! other accelerator (or none) it runs once and the future is ready at once. Desktop threads
|
|
//! drive the same futures with [`block_on`].
|
|
|
|
use std::future::Future;
|
|
use std::task::{Context, Poll, Waker};
|
|
|
|
use effectcraft_project::ItemId;
|
|
use effectcraft_raster::Image;
|
|
use effectcraft_time::Tick;
|
|
|
|
use crate::{Accelerator, AutoKey, Backend, Renderer};
|
|
|
|
/// Passes before a frame gives up on deferred readbacks and renders on the CPU.
|
|
pub const MAX_PASSES: u32 = 24;
|
|
|
|
/// What [`in_passes`] returns.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Passed<T> {
|
|
pub value: T,
|
|
/// Render passes (1 without deferred readbacks).
|
|
pub passes: u32,
|
|
/// Rendered with the accelerator (not the CPU fallback after [`MAX_PASSES`]).
|
|
pub accelerated: bool,
|
|
}
|
|
|
|
/// Whether `accel` defers its readbacks (renders take passes).
|
|
pub fn is_deferred(accel: Option<&dyn Accelerator>) -> bool {
|
|
accel.is_some_and(|a| a.miss_gate().is_some())
|
|
}
|
|
|
|
/// Run `render` (one render with the accelerator it is given) until a pass doesn't miss: see
|
|
/// the module docs. After [`MAX_PASSES`] it renders once without the accelerator (the CPU).
|
|
/// Caches the render writes to must hold the accelerator's miss gate
|
|
/// ([`crate::LayerCache::set_gate`]).
|
|
pub async fn in_passes<'a, T>(accel: Option<&'a dyn Accelerator>, mut render: impl FnMut(Option<&'a dyn Accelerator>) -> T) -> Passed<T> {
|
|
let Some(a) = accel.filter(|a| a.miss_gate().is_some()) else {
|
|
return Passed { value: render(accel), passes: 1, accelerated: accel.is_some() };
|
|
};
|
|
a.frame_begin();
|
|
for pass in 1..=MAX_PASSES {
|
|
a.pass_begin();
|
|
let value = render(Some(a));
|
|
if !a.pass_missed() {
|
|
return Passed { value, passes: pass, accelerated: true };
|
|
}
|
|
match a.settle() {
|
|
Some(s) => s.await,
|
|
None => break,
|
|
}
|
|
}
|
|
Passed { value: render(None), passes: MAX_PASSES + 1, accelerated: false }
|
|
}
|
|
|
|
/// Ends the frame for the accelerator ([`Accelerator::frame_end`]) when dropped: also when the
|
|
/// render panics or the future is dropped between passes.
|
|
struct FrameEnd<'a>(&'a dyn Accelerator);
|
|
impl Drop for FrameEnd<'_> {
|
|
fn drop(&mut self) {
|
|
self.0.frame_end();
|
|
}
|
|
}
|
|
|
|
/// A top-level comp frame through [`in_passes`] ([`Renderer::comp_frame`] in passes). Backend
|
|
/// Auto picks CPU or GPU for the whole frame (all its passes) and is timed as a whole, as the
|
|
/// frame workers do. Ends the frame for the accelerator ([`Accelerator::frame_end`]) on every
|
|
/// exit, including a panic.
|
|
pub async fn comp_frame(r: &Renderer<'_>, comp: ItemId, t: Tick) -> Image {
|
|
let Some(a) = r.accel else { return r.comp_frame(comp, t) };
|
|
let _end = FrameEnd(a);
|
|
if a.miss_gate().is_none() {
|
|
return r.comp_frame(comp, t);
|
|
}
|
|
let mut opts = r.opts;
|
|
let mut auto = None;
|
|
if opts.backend == Backend::Auto {
|
|
if !r.project.settings.gpu_acceleration {
|
|
opts.backend = Backend::Cpu;
|
|
} else if opts.roi.is_none()
|
|
&& let Some(pick) = a.auto_pick()
|
|
{
|
|
let key = AutoKey::new(comp, opts.scale, false);
|
|
let gpu = pick.choose(key);
|
|
opts.backend = if gpu { Backend::Gpu } else { Backend::Cpu };
|
|
auto = Some((key, gpu));
|
|
}
|
|
}
|
|
let t0 = web_time::Instant::now();
|
|
let img = if opts.backend == Backend::Cpu {
|
|
Renderer { opts, accel: None, ..*r }.comp_frame(comp, t)
|
|
} else {
|
|
in_passes(Some(a), |acc| Renderer { opts, accel: acc, ..*r }.comp_frame(comp, t)).await.value
|
|
};
|
|
if let (Some((key, chose)), Some(pick)) = (auto, a.auto_pick()) {
|
|
pick.record(key, chose, t0.elapsed().as_secs_f64() * 1e3);
|
|
}
|
|
img
|
|
}
|
|
|
|
/// Drive `f` to completion on this thread. For futures that only wait on deferred readbacks,
|
|
/// whose [`Accelerator::settle`] blocks natively: they never stay pending there.
|
|
pub fn block_on<F: Future>(f: F) -> F::Output {
|
|
let mut f = std::pin::pin!(f);
|
|
let mut cx = Context::from_waker(Waker::noop());
|
|
loop {
|
|
if let Poll::Ready(v) = f.as_mut().poll(&mut cx) {
|
|
return v;
|
|
}
|
|
std::thread::yield_now();
|
|
}
|
|
}
|