Skip to main content

telar_renderer_core/
perf.rs

1//! Lightweight per-phase frame timing, gated on the `TELAR_PERF` env var. When disabled every
2//! entry point is a single relaxed-atomic load or an early return, so it is safe to leave the
3//! instrumentation compiled into release builds. Enabled it accumulates per-phase durations
4//! across both the UI thread (command build/clone) and the render thread (interpret/gpu) and
5//! dumps rolling averages via `tracing` — stdout on desktop, logcat on Android.
6
7use std::sync::OnceLock;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::time::{Duration, Instant};
10
11/// Frame phases attributed to CPU vs GPU so a baseline can split the ~16 ms budget.
12#[derive(Clone, Copy)]
13pub enum Phase {
14    /// UI thread: `tree.commands()` flatten + `dev.on_frame`.
15    Build = 0,
16    /// UI thread: the per-frame `Vec<DrawCommand>` clone handed to the render thread.
17    Clone = 1,
18    /// Render thread: `analyze_frame` (dirty/scroll detection) + `interpret_commands`.
19    Interpret = 2,
20    /// Render thread: segment build + pass execution + `queue.submit` (encompasses `present`).
21    Gpu = 3,
22    /// Whole render_frame (render thread) or whole SW render (UI thread).
23    Frame = 4,
24    /// Render thread: `output.present()` alone — a subset of `gpu` that isolates swapchain/vsync
25    /// block (FIFO present on mobile) from the CPU-side command-buffer build + submit.
26    Present = 5,
27}
28
29const N: usize = 6;
30const NAMES: [&str; N] = ["build", "clone", "interpret", "gpu", "frame", "present"];
31// Dump cadence in ticked frames; one line per ~second at 60 fps keeps logcat readable.
32const DUMP_EVERY: u64 = 60;
33
34static ENABLED: OnceLock<bool> = OnceLock::new();
35static SUMS: [AtomicU64; N] = [const { AtomicU64::new(0) }; N];
36static COUNTS: [AtomicU64; N] = [const { AtomicU64::new(0) }; N];
37static MAXES: [AtomicU64; N] = [const { AtomicU64::new(0) }; N];
38static FRAMES: AtomicU64 = AtomicU64::new(0);
39// Count of frames in the current window that took the F1 damage-tracking path, so the dump shows
40// whether damage is actually firing (vs falling back to a full repaint).
41static DAMAGE_FRAMES: AtomicU64 = AtomicU64::new(0);
42
43/// Record whether the frame being rendered used F1 damage tracking.
44#[inline]
45pub fn note_damage(active: bool) {
46    if active && enabled() {
47        DAMAGE_FRAMES.fetch_add(1, Ordering::Relaxed);
48    }
49}
50
51#[inline]
52fn enabled() -> bool {
53    *ENABLED.get_or_init(
54        || matches!(std::env::var("TELAR_PERF").as_deref(), Ok(v) if !v.is_empty() && v != "0"),
55    )
56}
57
58/// `Instant::now()` only when instrumentation is on, so disabled builds never read the clock.
59#[inline]
60pub fn now_if_enabled() -> Option<Instant> {
61    if enabled() {
62        Some(Instant::now())
63    } else {
64        None
65    }
66}
67
68#[inline]
69fn record(phase: Phase, dur: Duration) {
70    let i = phase as usize;
71    let ns = dur.as_nanos() as u64;
72    SUMS[i].fetch_add(ns, Ordering::Relaxed);
73    COUNTS[i].fetch_add(1, Ordering::Relaxed);
74    MAXES[i].fetch_max(ns, Ordering::Relaxed);
75}
76
77/// Record the elapsed time since a `now_if_enabled()` mark; a no-op when disabled.
78#[inline]
79pub fn record_since(phase: Phase, start: Option<Instant>) {
80    if let Some(t) = start {
81        record(phase, t.elapsed());
82    }
83}
84
85/// RAII span that records into `phase` on drop. `None` when disabled.
86pub struct Span {
87    phase: Phase,
88    start: Instant,
89}
90
91#[inline]
92pub fn span(phase: Phase) -> Option<Span> {
93    if enabled() {
94        Some(Span {
95            phase,
96            start: Instant::now(),
97        })
98    } else {
99        None
100    }
101}
102
103impl Drop for Span {
104    fn drop(&mut self) {
105        record(self.phase, self.start.elapsed());
106    }
107}
108
109/// Advance the frame counter and, every `DUMP_EVERY` frames, log rolling avg/max per phase and
110/// reset the accumulators. Call once per frame from the thread that owns the frame loop.
111pub fn tick() {
112    if !enabled() {
113        return;
114    }
115    let f = FRAMES.fetch_add(1, Ordering::Relaxed) + 1;
116    if f % DUMP_EVERY != 0 {
117        return;
118    }
119    let mut parts = String::new();
120    for i in 0..N {
121        let sum = SUMS[i].swap(0, Ordering::Relaxed);
122        let cnt = COUNTS[i].swap(0, Ordering::Relaxed);
123        let mx = MAXES[i].swap(0, Ordering::Relaxed);
124        if cnt == 0 {
125            continue;
126        }
127        let avg_us = (sum as f64 / cnt as f64) / 1000.0;
128        let max_us = mx as f64 / 1000.0;
129        parts.push_str(&format!("{}={avg_us:.0}/{max_us:.0}us(n{cnt}) ", NAMES[i]));
130    }
131    let damage = DAMAGE_FRAMES.swap(0, Ordering::Relaxed);
132    tracing::info!(target: "telar_perf", "perf[{DUMP_EVERY}f] {}damage={damage}", parts);
133}