effectcraft/crates/render/src/auto.rs

145 lines
5.5 KiB
Rust

//! [`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.
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, AutoStats>>,
}
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;
/// Render the frame for `key` on the GPU?
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 s = m.entry(key).or_default();
// Warm up both sides, alternating, GPU first.
let gpu_warm = s.declined || s.gpu_frames >= Self::WARMUP;
if !gpu_warm && s.gpu_frames <= s.cpu_frames {
return true;
}
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.
if s.since_probe >= Self::REPROBE {
s.since_probe = 0;
return !gpu_faster;
}
s.since_probe += 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 s = m.entry(key).or_default();
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 s = m.entry(key).or_default();
s.declined = true;
s.gpu_ms = None;
s.since_probe = 0;
}
}
/// The history of `key`.
pub fn stats(&self, key: AutoKey) -> Option<AutoStats> {
self.stats.lock().ok()?.get(&key).copied()
}
/// Forget all history (settings that change both paths' costs).
pub fn reset(&self) {
if let Ok(mut m) = self.stats.lock() {
m.clear();
}
}
}