Files
effectcraft/crates/render/src/auto.rs
T
nazar-gavrylyk db21c733b4 Auto: a frame's ticket ends it on every exit
AutoPick::ticket chooses a side and reports the frame's time once; dropped without one it ends the frame unmeasured. The viewer's frames, the renderer's top-level frames and frames rendered in passes use it, so an Auto CPU frame turned away by GPU admission (#3028), a cancelled export frame or any later exit no longer leaves its frame counted as running. The frame server keeps its choice across calls and still ends a replaced frame itself.
2026-11-10 15:07:57 +01:00

270 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! [`Backend::Auto`](crate::Backend::Auto)'s per-comp choice between the CPU and the GPU
//! compositor, from measured frame times.
//!
//! A light comp (a couple of small layers) composites faster on the CPU, which only touches
//! the layers' bounds, than on the GPU, which works on whole frames and reads the result back;
//! heavy comps (many layers, 3D, GPU effects) are faster on the GPU. Neither a layer count nor
//! a pixel count predicts the crossover across machines, so Auto measures: per comp, output
//! scale and path (readback / viewer display), it keeps an exponentially weighted average of
//! the CPU and GPU frame times and renders on the faster one. Each side is warmed up first
//! (its first frame, with layer cache misses and texture uploads, is not counted; the fastest of
//! the next ones starts the average), and the slower side is re-measured every
//! [`AutoPick::REPROBE`] frames so the choice follows the comp as it changes.
//!
//! Frames render several at a time (a preview's lanes), and a frame is only measured once it
//! is done: so at most one GPU warm-up frame, or re-probe frame on either side, runs at once,
//! and the frames asked for meanwhile go to the other side. And a frame's time counts per
//! frame running on its side at once: twelve CPU frames that each take 200 ms side by side cost
//! about 17 ms a frame, against a GPU frame of 175 ms on its own. Otherwise every lane of a starting preview took a
//! warm-up frame on a GPU that turned out 2–4× slower (an integrated GPU), all at once.
use std::collections::HashMap;
use std::sync::Mutex;
use effectcraft_project::ItemId;
/// Which frames share timing history: one comp at one output scale on one path.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct AutoKey {
pub comp: ItemId,
/// `RenderOpts::scale` bits.
pub scale: u64,
/// The viewer's display path (GPU frames stay on the device) rather than a readback.
pub display: bool,
}
impl AutoKey {
pub fn new(comp: ItemId, scale: f64, display: bool) -> AutoKey {
AutoKey { comp, scale: scale.to_bits(), display }
}
}
/// Timing history of one [`AutoKey`].
#[derive(Clone, Copy, Debug, Default)]
pub struct AutoStats {
/// Averaged ms/frame (`None` until a counted sample).
pub cpu_ms: Option<f64>,
pub gpu_ms: Option<f64>,
/// Frames measured on each side (the first is the uncounted warm-up).
pub cpu_frames: u32,
pub gpu_frames: u32,
/// Frames since the side not chosen was last measured.
pub since_probe: u32,
/// The GPU declined the last frame it was asked for (the CPU rendered it).
pub declined: bool,
}
/// Per-comp CPU / GPU timing history for [`Backend::Auto`](crate::Backend::Auto) (held by the
/// accelerator, see [`Accelerator::auto_pick`](crate::Accelerator::auto_pick)).
#[derive(Default)]
pub struct AutoPick {
stats: Mutex<HashMap<AutoKey, Entry>>,
}
/// One key's history, when its running warm-up or re-probe frame started on each side, and
/// how many of its frames are running on each side (`[CPU, GPU]`).
#[derive(Default)]
struct Entry {
stats: AutoStats,
measuring: [Option<web_time::Instant>; 2],
// Lowered by `record`, `declined` and `abandoned`: every frame asked for ends in one (an
// [`AutoTicket`] makes sure of it).
running: [u32; 2],
}
impl Entry {
/// A warm-up or re-probe frame is running on `gpu`'s side. It counts as done once
/// [`AutoPick::MEASURING_FOR`] has passed (a frame that failed without reporting back).
fn measuring(&self, gpu: bool) -> bool {
self.measuring[usize::from(gpu)].is_some_and(|t| t.elapsed() < AutoPick::MEASURING_FOR)
}
/// Render the next warm-up or re-probe frame on `gpu`'s side, unless one is running there:
/// then the other side's (`!gpu`).
fn measure(&mut self, gpu: bool) -> bool {
if self.measuring(gpu) {
return !gpu;
}
self.measuring[usize::from(gpu)] = Some(web_time::Instant::now());
gpu
}
}
impl AutoPick {
/// Frames measured on each side before choosing (the first is not counted).
pub const WARMUP: u32 = 3;
/// Weight of a new sample in the running average (slower / faster than the average).
pub const ALPHA: f64 = 0.3;
pub const ALPHA_DOWN: f64 = 0.5;
/// Re-measure the side not chosen after this many frames.
pub const REPROBE: u32 = 48;
/// The CPU wins only when clearly faster (ties and noise stay on the GPU).
pub const MARGIN: f64 = 0.85;
/// Keys remembered before the history is reset.
const MAX_KEYS: usize = 1024;
/// Frames counted as running on one side at most (see `Entry::running`).
const MAX_RUNNING: u32 = 256;
/// A warm-up or re-probe frame not reported back by then no longer holds its side.
const MEASURING_FOR: std::time::Duration = std::time::Duration::from_secs(10);
/// [`AutoPick::choose`] for a frame that ends where it started: the ticket reports back, and
/// a ticket dropped without a time (an error, a retry elsewhere, a panic, a cancelled future)
/// ends the frame with [`AutoPick::abandoned`].
pub fn ticket(&self, key: AutoKey) -> AutoTicket<'_> {
AutoTicket { pick: self, key, gpu: self.choose(key), open: true }
}
/// Render the frame for `key` on the GPU? Every frame asked for reports back with
/// [`AutoPick::record`] (or [`AutoPick::declined`]), or ends with [`AutoPick::abandoned`]
/// ([`AutoPick::ticket`] does that on its own).
pub fn choose(&self, key: AutoKey) -> bool {
let Ok(mut m) = self.stats.lock() else { return true };
if m.len() > Self::MAX_KEYS && !m.contains_key(&key) {
m.clear();
}
let e = m.entry(key).or_default();
let gpu = Self::pick(e);
let n = &mut e.running[usize::from(gpu)];
*n = n.saturating_add(1).min(Self::MAX_RUNNING);
gpu
}
fn pick(e: &mut Entry) -> bool {
let s = e.stats;
// Warm up both sides, alternating, CPU first: the first frames asked for are the
// ones on screen, and the CPU is the side that is never slow to start (no pipelines
// to build; an integrated GPU took 2 s for the frame at the playhead).
let gpu_warm = s.declined || s.gpu_frames >= Self::WARMUP;
if !gpu_warm && s.gpu_frames < s.cpu_frames {
return e.measure(true);
}
// (CPU frames already render side by side, on lanes of their own.)
if s.cpu_frames < Self::WARMUP {
return false;
}
let gpu_faster = match (s.gpu_ms, s.cpu_ms) {
(Some(g), Some(c)) => c >= g * Self::MARGIN,
(g, _) => g.is_some(),
};
// Periodically re-measure the slower side (one frame at a time).
if s.since_probe >= Self::REPROBE && !e.measuring(!gpu_faster) {
e.stats.since_probe = 0;
return e.measure(!gpu_faster);
}
e.stats.since_probe = s.since_probe.saturating_add(1);
gpu_faster
}
/// A frame for `key` took `ms` on the GPU (`gpu`) or the CPU.
///
/// The warm-up keeps the fastest of its counted frames; after that the average follows
/// improvements quickly and slow-downs gradually, and a single frame counts at most twice
/// the average (a stall of the machine does not flip the choice for a whole re-probe
/// period).
pub fn record(&self, key: AutoKey, gpu: bool, ms: f64) {
let Ok(mut m) = self.stats.lock() else { return };
let e = m.entry(key).or_default();
e.measuring[usize::from(gpu)] = None;
// Per frame running on this side alongside it (itself included).
let n = &mut e.running[usize::from(gpu)];
let ms = ms / f64::from((*n).max(1));
*n = n.saturating_sub(1);
let s = &mut e.stats;
let (avg, frames) = if gpu { (&mut s.gpu_ms, &mut s.gpu_frames) } else { (&mut s.cpu_ms, &mut s.cpu_frames) };
*frames = frames.saturating_add(1);
if gpu {
s.declined = false;
}
if *frames < 2 {
return;
}
*avg = Some(match *avg {
None => ms,
Some(a) if *frames <= Self::WARMUP => a.min(ms),
Some(a) if ms < a => a + (ms - a) * Self::ALPHA_DOWN,
Some(a) => a + (ms.min(2.0 * a) - a) * Self::ALPHA,
});
}
/// The GPU did not handle a frame for `key` (it rendered on the CPU instead).
pub fn declined(&self, key: AutoKey) {
if let Ok(mut m) = self.stats.lock() {
let e = m.entry(key).or_default();
e.measuring[1] = None;
// The frame renders on the CPU instead (and reports back from there).
e.running[1] = e.running[1].saturating_sub(1);
e.running[0] = e.running[0].saturating_add(1).min(Self::MAX_RUNNING);
let s = &mut e.stats;
s.declined = true;
s.gpu_ms = None;
s.since_probe = 0;
}
}
/// A frame for `key` asked of `gpu`'s side ends without a time (it failed, or was replaced
/// before it finished). Without this its side stays counted as running, and every later
/// frame there is timed as one of several running side by side: that side looks cheaper.
// ponytail: clears the side's warm-up/probe mark even when another frame holds it; that
// only lets the next probe start early.
pub fn abandoned(&self, key: AutoKey, gpu: bool) {
if let Ok(mut m) = self.stats.lock()
&& let Some(e) = m.get_mut(&key)
{
e.measuring[usize::from(gpu)] = None;
let n = &mut e.running[usize::from(gpu)];
*n = n.saturating_sub(1);
}
}
/// The history of `key`.
pub fn stats(&self, key: AutoKey) -> Option<AutoStats> {
self.stats.lock().ok()?.get(&key).map(|e| e.stats)
}
/// Forget all history (settings that change both paths' costs).
pub fn reset(&self) {
if let Ok(mut m) = self.stats.lock() {
m.clear();
}
}
}
/// One frame [`AutoPick`] chose a side for ([`AutoPick::ticket`]): it reports the frame's time
/// once, and ends the frame unmeasured when dropped without one.
#[must_use = "a ticket dropped at once ends its frame unmeasured"]
pub struct AutoTicket<'a> {
pick: &'a AutoPick,
key: AutoKey,
gpu: bool,
open: bool,
}
impl AutoTicket<'_> {
/// The frame renders on the GPU.
pub fn gpu(&self) -> bool {
self.gpu
}
/// The frame took `ms` on its side.
pub fn record(mut self, ms: f64) {
self.open = false;
self.pick.record(self.key, self.gpu, ms);
}
/// The GPU did not take the frame: it renders on the CPU, and the ticket reports from there.
pub fn declined(&mut self) {
if self.gpu {
self.pick.declined(self.key);
self.gpu = false;
}
}
}
impl Drop for AutoTicket<'_> {
fn drop(&mut self) {
if self.open {
self.pick.abandoned(self.key, self.gpu);
}
}
}