frust_shell_common/perf.rs
1//! Frame-timing and startup-span perf instrumentation shared by every shell.
2//!
3//! # What lives here
4//!
5//! - [`FrameStats`] — a per-shell recorder of one frame's pass durations
6//! (rebuild/layout/paint/encode/acquire/submit, plus a `skipped` marker the
7//! mobile dirty-gate sets), aggregated into a ring buffer
8//! plus running totals; [`FrameStats::summary`] reports p50/p95/p99 total
9//! frame time, per-pass p95, and frames-over-budget counts against the
10//! 16.6ms/8.3ms (60Hz/120Hz) targets.
11//! - [`StartupSpans`] — named monotonic timestamps from a shell's `begin()`
12//! epoch (native-lib load, init entry, adapter/device/renderer ready,
13//! first rebuild done, first frame presented — see the `SPAN_*` consts),
14//! summarized into one log line.
15//! - [`enabled`] — the process-wide on/off switch every recording API is a
16//! no-op behind (see its own docs).
17//! - [`raw_enabled`] — a second dial that, alongside
18//! [`enabled`], makes [`FrameStats::record`] additionally emit one
19//! `frust-perf raw` line per recorded frame (instead of only the
20//! rate-limited ~2s `frust-perf frame` summary [`FrameStats::emit_log`]
21//! already produces).
22//! - [`mark_scenario_start`]/[`mark_scenario_end`] — the benchmark
23//! scenario-window edges a harness slices that per-frame series by. They
24//! ride [`enabled`] alone (not the raw dial), and they are *queued* rather
25//! than logged: [`FrameStats::record`] emits each one stamped with the
26//! number of the frame that actually carried it, so a window survives the
27//! render-thread split's UI/render interleaving. The whole route is
28//! `perf-trace`-only (a release-lean build has no marker code at all) and
29//! **per-thread** — a marker belongs to the thread that raised it until
30//! that thread hands a frame off or records one. See
31//! [`mark_scenario_start`] for the route, the half-open window rule, and
32//! what happens to a marker raised on a thread that does neither.
33//!
34//! # Layering choice
35//!
36//! This lives in `frust-shell-common`, not `frust-core` — timing is
37//! shell-owned by design (`docs/CODE_STANDARDS.md`'s "no `Instant::now()` in
38//! `frust-core`/`frust-widgets`" rule binds the framework layers only;
39//! a shell reading a wall clock to time its own passes is exactly the kind
40//! of shell-facing responsibility this crate already carries alongside
41//! `theme_override`/`ffi_support`). Every recording API takes an
42//! already-measured [`std::time::Duration`] rather than reading a clock
43//! itself, and [`StartupSpans`] is generic over an injectable [`Clock`] —
44//! this crate's own logic stays fully host-testable without a real clock.
45//!
46//! # Wiring
47//!
48//! This module ships the recorder + switch; the Android, iOS, and desktop
49//! shells each construct and feed a [`FrameStats`]/[`StartupSpans`] of
50//! their own.
51
52#[cfg(all(test, feature = "perf-trace"))]
53use std::cell::Cell;
54#[cfg(feature = "perf-trace")]
55use std::cell::RefCell;
56use std::collections::VecDeque;
57#[cfg(feature = "perf-trace")]
58use std::sync::OnceLock;
59use std::time::{Duration, Instant};
60
61/// Ring-buffer capacity for [`FrameStats`] — roughly 2 seconds of frames at
62/// 60Hz, enough for a stable rolling percentile without unbounded growth.
63pub const RING_CAPACITY: usize = 120;
64
65/// The 60Hz frame budget (1000/60 ms), truncated to whole microseconds.
66pub const BUDGET_60HZ: Duration = Duration::from_micros(16_667);
67
68/// The 120Hz frame budget (1000/120 ms), truncated to whole microseconds.
69pub const BUDGET_120HZ: Duration = Duration::from_micros(8_333);
70
71/// Minimum span of recorded frame time between two [`FrameStats::emit_log`]
72/// calls that [`FrameStats::should_emit`] requires — one line every ~2s of
73/// frames, measured in accumulated frame time rather than
74/// wall-clock time so it needs no clock of its own.
75const EMIT_INTERVAL: Duration = Duration::from_secs(2);
76
77// ---------------------------------------------------------------------
78// On/off switch
79// ---------------------------------------------------------------------
80//
81// The whole of this module is *runtime*-switchable via [`enabled`], but that
82// runtime dial only exists when the crate's `perf-trace` cargo feature is on.
83// Without the feature every `frust-perf`/`bench-scenario` string literal and
84// its `log::info!` emission is `#[cfg]`-compiled out entirely (release-lean
85// builds), and [`enabled`]/[`raw_enabled`] collapse to inlinable `false`
86// constants so downstream `if enabled()` branches constant-fold away — the CLI
87// turns the feature on for debug/profile builds and omits it for release (the
88// Flutter-mode-parity split, see the release-lean plan). The public perf API
89// (types, constructors, `record`/`summary`/`emit_log`/`mark_scenario_*`)
90// compiles identically in both configurations; only the string-bearing
91// emission internals are gated, so no shell call site changes.
92//
93// Sink decision (feeds the release-lean log-level ceiling): `frust-perf`
94// lines keep flowing through the `log` facade (`log::info!`), NOT a bypassing
95// `eprintln!`/platform sink. Rationale — this crate is deliberately
96// platform-free (no `android_logger`/NDK), so it cannot replicate each
97// platform's real channel (logcat on Android, the desktop/iOS stderr logger),
98// and moving Android's lines off logcat would break the benchmark harness's
99// log parsing; keeping `log::info!` guarantees byte-identical output on every
100// platform. The consequence this MUST honor: perf is stripped from release
101// by THIS FEATURE (off ⇒ code+strings gone), never by the log level. So
102// `release_max_level_warn` must be applied to RELEASE ONLY (e.g. a
103// CLI-toggled `log/release_max_level_warn` cargo feature enabled for
104// `--release` and omitted for `--profile`), never as an always-on manifest
105// feature: `release_max_level_*` keys off `debug_assertions`, which is OFF in
106// the profile profile too, so an always-on ceiling would silence profile-mode
107// perf lines. Release perf lines don't exist to strip
108// (feature off), so a release-only ceiling only removes stray non-perf
109// info/debug while profile keeps its `frust-perf` output intact.
110
111/// The process-wide perf-instrumentation switch, cached after the first
112/// call. `true` when either:
113///
114/// - the compile-time `FRUST_TRACE` define is set to a non-`"0"` value
115/// (the `--define`/`--profile` path — `--profile` builds
116/// pass `FRUST_TRACE=1` by default; `option_env!` reads whatever a
117/// build script/cargo-ndk env var set at compile time), or
118/// - the runtime `FRUST_TRACE` process environment variable is set to a
119/// non-`"0"` value (desktop dev: `FRUST_TRACE=1 cargo run -p ...`) — the
120/// compile-time-or-runtime convention every shipping `FRUST_*` knob
121/// follows.
122///
123/// Every recording API in this module (`FrameStats::record`,
124/// `StartupSpans::record`, both `emit_log`s) is a cheap no-op when this is
125/// `false` — no allocation, no clock read, on the hot path.
126///
127/// Cached in a `OnceLock`: the switch is read once per process and never
128/// changes afterward, so this is intentionally not re-evaluatable at
129/// runtime — a shell that needs to bypass the cache for testing should
130/// construct a [`FrameStats`]/[`StartupSpans`] via the explicit
131/// `*_enabled`/`begin_with` constructors instead of relying on this
132/// function's cache.
133#[cfg(feature = "perf-trace")]
134pub fn enabled() -> bool {
135 static ENABLED: OnceLock<bool> = OnceLock::new();
136 *ENABLED
137 .get_or_init(|| trace_switch(option_env!("FRUST_TRACE"), runtime_trace_var().as_deref()))
138}
139
140/// The `perf-trace` feature is off: perf instrumentation is compiled out of
141/// this build, so the switch is a compile-time `false` constant — no
142/// `OnceLock`, no environment read. `#[inline]` so every downstream
143/// `if enabled()` branch constant-folds to nothing, taking the `frust-perf`
144/// emission (and its strings) with it via LLVM dead-code elimination. Build
145/// with `--features perf-trace` (what a debug/profile build does) to restore
146/// the runtime `FRUST_TRACE` switch documented above.
147#[cfg(not(feature = "perf-trace"))]
148#[inline]
149pub fn enabled() -> bool {
150 false
151}
152
153/// Reads the runtime `FRUST_TRACE` env var, isolated into its own
154/// function so [`enabled`]'s caching is the only thing that touches the
155/// process environment — [`trace_switch`] itself stays a pure, directly
156/// unit-testable function. Compiled only under `perf-trace` (the only caller,
157/// [`enabled`]'s feature-on arm, is too).
158#[cfg(feature = "perf-trace")]
159fn runtime_trace_var() -> Option<String> {
160 std::env::var("FRUST_TRACE").ok()
161}
162
163/// The pure decision [`enabled`] caches: non-empty and not the literal
164/// string `"0"` counts as "set" for either the compile-time or runtime
165/// value; either source being set is enough. Compiled only under `perf-trace`.
166#[cfg(feature = "perf-trace")]
167fn trace_switch(compile_time: Option<&str>, runtime: Option<&str>) -> bool {
168 fn is_set_non_zero(value: Option<&str>) -> bool {
169 matches!(value, Some(v) if v != "0")
170 }
171 is_set_non_zero(compile_time) || is_set_non_zero(runtime)
172}
173
174/// The process-wide raw-per-frame-export switch, cached after
175/// the first call — parsed exactly the same compile-time-or-runtime way as
176/// [`enabled`] (via the same [`trace_switch`] decision), but reading
177/// `FRUST_TRACE_RAW` instead of `FRUST_TRACE`. This is a **second dial**,
178/// not a replacement: `FRUST_TRACE_RAW` being set implies nothing on its own
179/// — [`FrameStats::record`]'s raw per-frame line only fires when [`enabled`]
180/// is *also* true (a benchmark harness sets both `FRUST_TRACE=1` and
181/// `FRUST_TRACE_RAW=1`; see [`FrameStats::new`]).
182#[cfg(feature = "perf-trace")]
183pub fn raw_enabled() -> bool {
184 static RAW_ENABLED: OnceLock<bool> = OnceLock::new();
185 *RAW_ENABLED.get_or_init(|| {
186 trace_switch(
187 option_env!("FRUST_TRACE_RAW"),
188 runtime_trace_raw_var().as_deref(),
189 )
190 })
191}
192
193/// The `perf-trace` feature is off: the raw-per-frame dial is compiled out
194/// alongside [`enabled`], so it is a compile-time `false` constant (see
195/// [`enabled`]'s feature-off arm for the DCE rationale).
196#[cfg(not(feature = "perf-trace"))]
197#[inline]
198pub fn raw_enabled() -> bool {
199 false
200}
201
202/// Reads the runtime `FRUST_TRACE_RAW` env var, isolated for the same reason
203/// [`runtime_trace_var`] is. Compiled only under `perf-trace`.
204#[cfg(feature = "perf-trace")]
205fn runtime_trace_raw_var() -> Option<String> {
206 std::env::var("FRUST_TRACE_RAW").ok()
207}
208
209// ---------------------------------------------------------------------
210// FrameStats
211// ---------------------------------------------------------------------
212
213/// One frame's measured pass durations, as recorded by a shell's frame
214/// callback (`AndroidAppHandle::frame` / `frust_render_frame` /
215/// desktop's `RedrawRequested` handler — see `docs/ARCHITECTURE.md`'s Frame
216/// pipeline). `skipped` is set by the mobile dirty-gate for a
217/// frame whose passes never ran; its pass durations are `Duration::ZERO` in
218/// that case and it is excluded from the percentile computation in
219/// [`FrameStats::summary`] (see that method's docs) while still counting
220/// toward `total_frames`/`skipped_frames`.
221#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
222pub struct FramePasses {
223 pub rebuild: Duration,
224 pub layout: Duration,
225 pub paint: Duration,
226 /// GPU/CPU encode cost — the [`SurfaceRenderer::encode`] span
227 /// (`vello` encode + `render_to_texture`, or the CPU rasterize+upload),
228 /// with no swapchain-acquire wait folded in. Split out from the old
229 /// combined `encode_present` so encode work and the vsync/
230 /// present wait are separately attributable — the number the
231 /// render-thread-split GO/NO-GO decision is made on.
232 ///
233 /// [`SurfaceRenderer::encode`]: https://docs.rs/frust-render
234 pub encode: Duration,
235 /// Swapchain-**acquire** cost — the [`SurfaceRenderer::acquire`] span,
236 /// dominated by the blocking vsync wait (per the surface's present mode).
237 /// Split out from the old combined `present` so the blocking
238 /// vsync wait is attributable separately from the blit/submit work below —
239 /// the S5 GPU-saturation-vs-blit-cost question. `acquire + submit` equals the
240 /// old v2 `present` span, so cross-baseline math is unchanged.
241 ///
242 /// [`SurfaceRenderer::acquire`]: https://docs.rs/frust-render
243 pub acquire: Duration,
244 /// Blit + queue-submit + present cost — the [`SurfaceRenderer::submit`]
245 /// span, the GPU/driver work after the swapchain texture is acquired. See
246 /// [`Self::acquire`] for the split rationale.
247 ///
248 /// [`SurfaceRenderer::submit`]: https://docs.rs/frust-render
249 pub submit: Duration,
250 pub skipped: bool,
251 /// This frame's real GPU time per pass, when the renderer measured it —
252 /// see [`GpuPasses`]. `None` is the ordinary case and is what the raw line
253 /// reports as `gpu_q=0`.
254 ///
255 /// Deliberately outside [`Self::total`]: GPU passes run *concurrently*
256 /// with the CPU spans above, so adding them would double-count the frame.
257 /// It is also not carried by [`RenderSpans`] — a shell attaches it with
258 /// [`Self::with_gpu`] after folding its two measured halves together,
259 /// which keeps the split's own reassembly about the spans it measured.
260 pub gpu: Option<GpuPasses>,
261}
262
263/// One frame's real GPU time, split by the spans the frust-owned render engine
264/// names — the counterpart of the CPU spans in [`FramePasses`], measured on the
265/// GPU's own clock rather than inferred from CPU wall time around a submit.
266///
267/// Produced only in a build whose device asked for GPU timestamps; every
268/// other frame carries `None` and reports `gpu_q=0`.
269/// A span may legitimately read zero — a frame with no off-screen layer does no
270/// composite work — so a zero is a measurement, not a gap.
271#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
272pub struct GpuPasses {
273 /// Work recorded ahead of the frame's own passes (the glyph-atlas replay).
274 pub prepass: Duration,
275 /// The frame's own surface passes: clear, opaque strips, alpha strips, and
276 /// the hole punch.
277 pub main: Duration,
278 /// Off-screen layer pages and filter passes.
279 pub composite: Duration,
280 /// The present-side conversion or blit into the swapchain.
281 pub blit: Duration,
282}
283
284impl GpuPasses {
285 /// Whether the `main` span carries a real measurement.
286 ///
287 /// Every submitted frame records its opening surface pass into `main`, and
288 /// a measured timestamp pair is strictly increasing, so a measured `main`
289 /// is never exactly [`Duration::ZERO`]. A zero here therefore means the
290 /// pair was never resolved (a clear-only opening pass that drew nothing has
291 /// no valid pair on Metal), not that the pass took no time. The other
292 /// three spans may legitimately be zero (a frame with no off-screen layer
293 /// does no composite work), so only `main` can be judged this way.
294 pub fn main_is_measured(&self) -> bool {
295 !self.main.is_zero()
296 }
297
298 /// The sum of the four spans — the frame's *attributed* GPU pass time.
299 ///
300 /// Queue and driver gaps between passes belong to no pass and are not
301 /// folded in, so this is a lower bound on the frame's whole GPU cost
302 /// rather than an estimate of it.
303 pub fn total(&self) -> Duration {
304 self.prepass
305 .saturating_add(self.main)
306 .saturating_add(self.composite)
307 .saturating_add(self.blit)
308 }
309}
310
311impl FramePasses {
312 /// The sum of all six pass durations — the frame's total wall time.
313 ///
314 /// The GPU spans in [`Self::gpu`] are deliberately excluded; see that
315 /// field.
316 pub fn total(&self) -> Duration {
317 self.rebuild + self.layout + self.paint + self.encode + self.acquire + self.submit
318 }
319
320 /// Attaches this frame's measured GPU pass times.
321 ///
322 /// A builder rather than a field on [`RenderSpans`]: every shell already
323 /// builds its `RenderSpans` by struct literal, and the GPU reading is
324 /// optional per frame and per tier, so `FramePasses::from_split(ui,
325 /// render).with_gpu(gpu)` adds it without touching the spans a shell
326 /// measured itself.
327 #[must_use]
328 pub fn with_gpu(mut self, gpu: GpuPasses) -> Self {
329 self.gpu = Some(gpu);
330 self
331 }
332
333 /// Recombine a render-thread-split frame's two half-measurements into the
334 /// one [`FramePasses`] the single emitter records.
335 ///
336 /// In the render-thread split the UI thread measures
337 /// `rebuild`/`layout`/`paint` ([`UiSpans`]) while the render thread measures
338 /// `encode`/`acquire`/`submit` ([`RenderSpans`]); the UI half rides across
339 /// the scene-handoff channel
340 /// ([`SceneFrame`](crate::render_split::SceneFrame)) so the render thread —
341 /// the **single emitter** — can fold both halves into one frame record.
342 /// This is a pure reassembly of the *existing* six fields: it changes no
343 /// wire format (the raw v3 line [`format_raw_frame_line`] emits is byte-for-
344 /// byte identical to a single-thread frame's), it only moves *where* each
345 /// span is measured. The `skipped` flag comes from the UI half (the frame
346 /// gate is UI-side — see [`crate::frame_gate`]).
347 pub fn from_split(ui: UiSpans, render: RenderSpans) -> Self {
348 Self {
349 rebuild: ui.rebuild,
350 layout: ui.layout,
351 paint: ui.paint,
352 encode: render.encode,
353 acquire: render.acquire,
354 submit: render.submit,
355 skipped: ui.skipped,
356 // Not a measured span either half carries: the render thread reads
357 // it off the renderer after the fact and attaches it with
358 // [`FramePasses::with_gpu`].
359 gpu: None,
360 }
361 }
362}
363
364/// The UI-thread half of a render-thread-split frame's timing: the
365/// `rebuild`/`layout`/`paint` spans measured on the UI thread,
366/// plus the frame gate's `skipped` verdict (the gate stays UI-side — see
367/// [`crate::frame_gate`]). Rides the scene-handoff channel across to the
368/// render thread, which folds it together with its own [`RenderSpans`] via
369/// [`FramePasses::from_split`] and records the result through the one emitter.
370#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
371pub struct UiSpans {
372 pub rebuild: Duration,
373 pub layout: Duration,
374 pub paint: Duration,
375 /// The frame gate's skip verdict (a skipped frame carries all-zero spans);
376 /// preserved through [`FramePasses::from_split`] into the recorded frame.
377 pub skipped: bool,
378}
379
380/// The render-thread half of a render-thread-split frame's timing: the
381/// `encode`/`acquire`/`submit` spans measured on the render thread,
382/// folded together with the UI thread's [`UiSpans`] via
383/// [`FramePasses::from_split`]. See [`FramePasses::encode`]/[`FramePasses::acquire`]/
384/// [`FramePasses::submit`] for each span's exact boundary (the v3 attribution
385/// this split preserves unchanged).
386#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
387pub struct RenderSpans {
388 pub encode: Duration,
389 pub acquire: Duration,
390 pub submit: Duration,
391}
392
393/// A rolling summary over [`FrameStats`]'s current ring-buffer window plus
394/// the lifetime running counters — the shape [`FrameStats::emit_log`]'s log
395/// line reports and tests assert against.
396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
397pub struct FrameSummary {
398 /// Frames currently held in the ring buffer (`<= RING_CAPACITY`), i.e.
399 /// how many samples the percentiles below are computed over.
400 pub frame_count: usize,
401 pub total_p50: Duration,
402 pub total_p95: Duration,
403 pub total_p99: Duration,
404 pub rebuild_p95: Duration,
405 pub layout_p95: Duration,
406 pub paint_p95: Duration,
407 /// p95 of the [`FramePasses::encode`] span (GPU/CPU encode, no vsync wait).
408 pub encode_p95: Duration,
409 /// p95 of the [`FramePasses::acquire`] span (swapchain-acquire/vsync wait).
410 pub acquire_p95: Duration,
411 /// p95 of the [`FramePasses::submit`] span (blit + queue-submit + present).
412 pub submit_p95: Duration,
413 /// Lifetime count of frames whose total exceeded [`BUDGET_60HZ`]
414 /// (16.6ms) — a running total, not windowed to the ring buffer.
415 pub over_60hz_budget: u64,
416 /// Lifetime count of frames whose total exceeded [`BUDGET_120HZ`]
417 /// (8.3ms) — a running total, not windowed to the ring buffer.
418 pub over_120hz_budget: u64,
419 /// Lifetime count of frames recorded with `FramePasses::skipped == true`
420 /// — a running total, not windowed to the ring buffer.
421 pub skipped_frames: u64,
422}
423
424/// Per-shell frame-timing recorder: a ring buffer of
425/// the last [`RING_CAPACITY`] frames' [`FramePasses`] plus lifetime running
426/// counters, aggregated on demand by [`FrameStats::summary`] and rate-limit
427/// logged by [`FrameStats::should_emit`]/[`FrameStats::emit_log`].
428///
429/// The Android, iOS, and desktop shells each construct one of these — a
430/// standalone, host-testable recorder.
431#[derive(Debug)]
432pub struct FrameStats {
433 enabled: bool,
434 /// Raw-per-frame-export mode (see [`raw_enabled`]) — always
435 /// `false` when `enabled` is `false` (the two-dial contract
436 /// [`Self::with_capacity_enabled_and_raw`] enforces). Only *read* by the
437 /// `perf-trace`-gated raw emission path in [`Self::record`], so it is dead
438 /// in a release-lean (feature-off) build (still written by constructors).
439 #[cfg_attr(not(feature = "perf-trace"), allow(dead_code))]
440 raw: bool,
441 /// Reused, cleared-and-rewritten each call so [`Self::record`]'s raw
442 /// line never grows the allocation once its capacity settles —
443 /// formatting only, no per-frame allocation growth (see
444 /// `docs/CODE_STANDARDS.md`'s Instrumentation conventions). Only ever
445 /// *read* by the `perf-trace`-gated raw emission path, so it is dead in a
446 /// release-lean (feature-off) build — the field stays (constructors still
447 /// size it) but the lint is silenced there.
448 #[cfg_attr(not(feature = "perf-trace"), allow(dead_code))]
449 raw_buf: String,
450 ring_capacity: usize,
451 ring: VecDeque<FramePasses>,
452 total_frames: u64,
453 skipped_frames: u64,
454 over_60hz: u64,
455 over_120hz: u64,
456 /// Accumulated frame total time since the last `emit_log` (or since
457 /// construction) — how [`Self::should_emit`] rate-limits without
458 /// needing its own clock (see [`EMIT_INTERVAL`]'s docs).
459 since_last_emit: Duration,
460 /// Test-only mirror of every scenario-marker line [`Self::record`] has
461 /// logged (and the queue-overflow notice beside them, when one was
462 /// emitted), in emission order — the seam the marker tests assert
463 /// against so they never have to install a global `log` sink (which
464 /// would make them order-dependent on every other test). Absent
465 /// from any non-test build, so it costs a shipped binary nothing, and
466 /// absent from a feature-off test build too — with the emission itself
467 /// compiled out there, a buffer of emitted lines would be a field
468 /// nothing ever writes.
469 #[cfg(all(test, feature = "perf-trace"))]
470 marker_log: Vec<String>,
471}
472
473impl FrameStats {
474 /// A recorder honoring the process-wide [`enabled`]/[`raw_enabled`]
475 /// switches — what every shell constructs.
476 pub fn new() -> Self {
477 let is_enabled = enabled();
478 Self::with_capacity_enabled_and_raw(RING_CAPACITY, is_enabled, is_enabled && raw_enabled())
479 }
480
481 /// Test/advanced seam: construct with an explicit enabled flag,
482 /// bypassing [`enabled`]'s cache. Every shell should prefer [`Self::new`];
483 /// this exists so tests can exercise both the enabled and disabled paths
484 /// deterministically in the same process (`enabled()`'s `OnceLock` can
485 /// only ever resolve once per process). Raw-export mode is left off; use
486 /// [`Self::with_capacity_enabled_and_raw`] to exercise it.
487 pub fn new_enabled(is_enabled: bool) -> Self {
488 Self::with_capacity_enabled(RING_CAPACITY, is_enabled)
489 }
490
491 /// Test seam: a smaller ring capacity, so eviction behavior is
492 /// exercisable without pushing [`RING_CAPACITY`] frames. Raw-export mode
493 /// is left off; use [`Self::with_capacity_enabled_and_raw`] to exercise
494 /// it.
495 pub fn with_capacity_enabled(capacity: usize, is_enabled: bool) -> Self {
496 Self::with_capacity_enabled_and_raw(capacity, is_enabled, false)
497 }
498
499 /// Test/advanced seam: construct with explicit enabled and raw-export
500 /// flags, bypassing both [`enabled`]'s and [`raw_enabled`]'s caches (see
501 /// [`Self::new_enabled`]'s docs for why a test needs to bypass the
502 /// cache). `is_raw` only takes effect when `is_enabled` is also `true` —
503 /// the same two-dial contract [`Self::new`] applies to the real
504 /// `FRUST_TRACE`/`FRUST_TRACE_RAW` switches.
505 pub fn with_capacity_enabled_and_raw(capacity: usize, is_enabled: bool, is_raw: bool) -> Self {
506 let raw = is_enabled && is_raw;
507 Self {
508 enabled: is_enabled,
509 raw,
510 // Disabled: never reserve — nothing will ever be formatted into
511 // it, mirroring the ring buffer's own no-reserve-when-disabled
512 // reasoning below.
513 // Sized for the longest line the formatter writes — the v4 shape
514 // with every `gpu_*_us` field present — so even a GPU-timed frame
515 // never grows the allocation after the first one.
516 raw_buf: if raw {
517 String::with_capacity(288)
518 } else {
519 String::new()
520 },
521 ring_capacity: capacity,
522 // Disabled: never reserve — nothing will ever be pushed, so
523 // "allocates nothing after init" holds
524 // trivially for the whole recorder's lifetime, not just after
525 // construction.
526 ring: if is_enabled {
527 VecDeque::with_capacity(capacity)
528 } else {
529 VecDeque::new()
530 },
531 total_frames: 0,
532 skipped_frames: 0,
533 over_60hz: 0,
534 over_120hz: 0,
535 since_last_emit: Duration::ZERO,
536 #[cfg(all(test, feature = "perf-trace"))]
537 marker_log: Vec::new(),
538 }
539 }
540
541 /// Record one frame's pass durations. A cheap no-op (no allocation, no
542 /// clock read — the caller already measured `passes`) when disabled.
543 /// When raw-export mode is on (see [`raw_enabled`]), additionally
544 /// formats and logs one `frust-perf raw` line for this frame — a skipped
545 /// frame (`passes.skipped`) still gets a line (all-zero pass durations,
546 /// `skipped=1`) so a harness can compute honest frame pacing across the
547 /// mobile frame gate.
548 ///
549 /// This is also the single point where a benchmark scenario marker is
550 /// emitted: every marker this frame carries — staged on this thread by
551 /// the scene handoff, or raised on this thread when there is no render
552 /// thread — is logged, in the order it was raised, stamped with **this**
553 /// frame's `n` and immediately ahead of this frame's own `frust-perf
554 /// raw` line, so a marker's frame number names the recorded frame that
555 /// actually carried it, not a guess made on whichever thread raised it
556 /// (see [`mark_scenario_start`]).
557 pub fn record(&mut self, passes: FramePasses) {
558 // Devtools frame stats fan out BEFORE the perf switch below: a devtools
559 // client subscribing to them is its own opt-in, independent of
560 // `FRUST_TRACE`. This is also the one site every shell's frame pipeline
561 // already funnels through — desktop, Android and iOS, inline and
562 // render-thread-split alike — so the publish has no per-shell copy to
563 // drift. Non-blocking, and one relaxed atomic load with no service
564 // running (see `crate::devtools::publish_frame`).
565 #[cfg(feature = "devtools")]
566 crate::devtools::publish_frame(&passes);
567
568 if !self.enabled {
569 return;
570 }
571
572 self.total_frames += 1;
573
574 // Ahead of everything else this frame emits (the `frust-perf raw`
575 // line below): a window's `start n=k` must precede frame k's own raw
576 // line in the log, and the harness's half-open `[start_n, end_n)`
577 // rule reads the numbers, not the positions.
578 #[cfg(feature = "perf-trace")]
579 self.emit_scenario_markers();
580
581 if passes.skipped {
582 self.skipped_frames += 1;
583 }
584
585 let total = passes.total();
586 if total > BUDGET_60HZ {
587 self.over_60hz += 1;
588 }
589 if total > BUDGET_120HZ {
590 self.over_120hz += 1;
591 }
592 self.since_last_emit += total;
593
594 #[cfg(feature = "perf-trace")]
595 if self.raw {
596 format_raw_frame_line(&mut self.raw_buf, self.total_frames, &passes);
597 log::info!("{}", self.raw_buf);
598 }
599
600 if self.ring.len() == self.ring_capacity {
601 self.ring.pop_front();
602 }
603 self.ring.push_back(passes);
604 }
605
606 /// Emit every scenario marker riding this frame, stamped with the frame
607 /// number [`Self::record`] just assigned it.
608 ///
609 /// Two sources, both belonging to **this** thread — which is what makes
610 /// the split and the inline executor one rule rather than two:
611 ///
612 /// - [`STAGED`] — markers put there by [`stage_markers`] when the render
613 /// channel handed this thread the very frame now being recorded. The
614 /// render-thread-split leg.
615 /// - [`PENDING`] — this thread's own raise queue. The inline (no render
616 /// thread) executor's leg, where the thread that raises a marker is the
617 /// same one that records the frame. On a render thread this is
618 /// *structurally* empty: a render thread raises no marker of its own,
619 /// and another thread's queue is unreachable from here, so nothing can
620 /// steal a marker still being built on the UI thread.
621 ///
622 /// Both drains are `is_empty` fast paths: a frame carrying no marker
623 /// allocates nothing.
624 ///
625 /// A marker line's shape is fixed (see [`format_scenario_marker`]); the
626 /// only extra line this can emit is the queue-overflow notice
627 /// [`format_marker_overflow_line`] writes, and only for a frame whose
628 /// markers arrived with a nonzero drop count (see [`MARKER_QUEUE_CAP`]).
629 #[cfg(feature = "perf-trace")]
630 fn emit_scenario_markers(&mut self) {
631 let mut markers = take_staged_markers();
632 markers.absorb(take_pending_markers());
633 for marker in markers.markers {
634 let line = format_scenario_marker(marker.edge, &marker.name, self.total_frames);
635 log::info!("{line}");
636 #[cfg(test)]
637 self.marker_log.push(line);
638 }
639 if markers.dropped > 0 {
640 let line = format_marker_overflow_line(self.total_frames, markers.dropped);
641 log::warn!("{line}");
642 #[cfg(test)]
643 self.marker_log.push(line);
644 }
645 }
646
647 /// Test-only view of the marker lines [`Self::record`] has emitted so
648 /// far, in order — see [`Self::marker_log`]'s docs.
649 /// `pub(crate)` so `render_split`'s channel tests can assert the lines a
650 /// frame carried across the handoff.
651 #[cfg(all(test, feature = "perf-trace"))]
652 pub(crate) fn marker_log(&self) -> &[String] {
653 &self.marker_log
654 }
655
656 /// Total frames ever recorded (including skipped), regardless of the
657 /// ring-buffer window.
658 pub fn total_frames(&self) -> u64 {
659 self.total_frames
660 }
661
662 /// Aggregate the current ring-buffer window plus the lifetime running
663 /// counters into a [`FrameSummary`].
664 ///
665 /// **Percentile semantics**: nearest-rank, computed over only the
666 /// *non-skipped* frames currently in the ring buffer (a skipped frame's
667 /// all-zero pass durations would otherwise silently pull percentiles
668 /// down and misrepresent real frame cost) — for a sorted-ascending
669 /// sample of `n` values, the `p`-th percentile is the value at 1-indexed
670 /// rank `ceil(p * n / 100)`, computed with integer ceiling division
671 /// (`(p * n).div_ceil(100)`) and clamped to `[1, n]`, so no floating-point
672 /// rounding is involved. An empty (or all-skipped) window reports
673 /// `Duration::ZERO` for every percentile field.
674 pub fn summary(&self) -> FrameSummary {
675 let active: Vec<&FramePasses> = self.ring.iter().filter(|p| !p.skipped).collect();
676
677 let mut totals: Vec<Duration> = active.iter().map(|p| p.total()).collect();
678 let mut rebuilds: Vec<Duration> = active.iter().map(|p| p.rebuild).collect();
679 let mut layouts: Vec<Duration> = active.iter().map(|p| p.layout).collect();
680 let mut paints: Vec<Duration> = active.iter().map(|p| p.paint).collect();
681 let mut encodes: Vec<Duration> = active.iter().map(|p| p.encode).collect();
682 let mut acquires: Vec<Duration> = active.iter().map(|p| p.acquire).collect();
683 let mut submits: Vec<Duration> = active.iter().map(|p| p.submit).collect();
684 totals.sort_unstable();
685 rebuilds.sort_unstable();
686 layouts.sort_unstable();
687 paints.sort_unstable();
688 encodes.sort_unstable();
689 acquires.sort_unstable();
690 submits.sort_unstable();
691
692 FrameSummary {
693 frame_count: self.ring.len(),
694 total_p50: nearest_rank_percentile(&totals, 50),
695 total_p95: nearest_rank_percentile(&totals, 95),
696 total_p99: nearest_rank_percentile(&totals, 99),
697 rebuild_p95: nearest_rank_percentile(&rebuilds, 95),
698 layout_p95: nearest_rank_percentile(&layouts, 95),
699 paint_p95: nearest_rank_percentile(&paints, 95),
700 encode_p95: nearest_rank_percentile(&encodes, 95),
701 acquire_p95: nearest_rank_percentile(&acquires, 95),
702 submit_p95: nearest_rank_percentile(&submits, 95),
703 over_60hz_budget: self.over_60hz,
704 over_120hz_budget: self.over_120hz,
705 skipped_frames: self.skipped_frames,
706 }
707 }
708
709 /// Whether at least [`EMIT_INTERVAL`] of frame time has accumulated
710 /// since the last [`Self::emit_log`] (or construction) — the "one line
711 /// every ~2s of frames" rate limit. Always `false` when disabled.
712 pub fn should_emit(&self) -> bool {
713 self.enabled && self.since_last_emit >= EMIT_INTERVAL
714 }
715
716 /// Emit one structured `frust-perf frame ...` line via `log::info!`
717 /// and reset the [`Self::should_emit`] accumulator. A no-op when
718 /// disabled. A shell calls this only when [`Self::should_emit`] is
719 /// `true` (it does not check it itself, so a test can force an emission
720 /// regardless of accumulated time).
721 pub fn emit_log(&mut self) {
722 if !self.enabled {
723 return;
724 }
725 // The `frust-perf frame` line (and the strings it carries) exists only
726 // under `perf-trace`; the accumulator reset below is plain bookkeeping
727 // and stays in every build so [`Self::should_emit`]'s rate limit
728 // behaves identically whether or not the feature is compiled in.
729 #[cfg(feature = "perf-trace")]
730 {
731 let s = self.summary();
732 log::info!(
733 "frust-perf frame n={} total_p50_ms={} total_p95_ms={} total_p99_ms={} \
734 rebuild_p95_ms={} layout_p95_ms={} paint_p95_ms={} encode_p95_ms={} \
735 acquire_p95_ms={} submit_p95_ms={} \
736 over_60hz={} over_120hz={} skipped={} total_frames={}",
737 s.frame_count,
738 s.total_p50.as_millis(),
739 s.total_p95.as_millis(),
740 s.total_p99.as_millis(),
741 s.rebuild_p95.as_millis(),
742 s.layout_p95.as_millis(),
743 s.paint_p95.as_millis(),
744 s.encode_p95.as_millis(),
745 s.acquire_p95.as_millis(),
746 s.submit_p95.as_millis(),
747 s.over_60hz_budget,
748 s.over_120hz_budget,
749 s.skipped_frames,
750 self.total_frames,
751 );
752 }
753 self.since_last_emit = Duration::ZERO;
754 }
755}
756
757impl Default for FrameStats {
758 fn default() -> Self {
759 Self::new()
760 }
761}
762
763/// Nearest-rank percentile over an ascending-sorted sample (see
764/// [`FrameStats::summary`]'s docs for the exact formula). `p` is a whole
765/// percentage (`50`/`95`/`99`); `sorted` must already be ascending.
766fn nearest_rank_percentile(sorted: &[Duration], p: u32) -> Duration {
767 let n = sorted.len() as u32;
768 if n == 0 {
769 return Duration::ZERO;
770 }
771 let rank = (p * n).div_ceil(100);
772 let rank = rank.clamp(1, n);
773 sorted[(rank - 1) as usize]
774}
775
776// ---------------------------------------------------------------------
777// Raw per-frame export + scenario markers
778// ---------------------------------------------------------------------
779
780/// Log-line prefix for [`FrameStats::record`]'s raw per-frame export line —
781/// parallels the `frust-perf frame`/`frust-perf startup` prefixes
782/// [`FrameStats::emit_log`]/[`StartupSpans::emit_log`] already use. A
783/// `frust-perf` string literal, so it compiles only under `perf-trace`
784/// (release-lean builds carry no `frust-perf` bytes).
785#[cfg(feature = "perf-trace")]
786const RAW_FRAME_PREFIX: &str = "frust-perf raw";
787
788/// Formats one `frust-perf raw` line into `buf` (cleared first) for frame
789/// index `n` (1-indexed — [`FrameStats::record`] passes its running
790/// `total_frames` counter, post-increment) and `passes`. Kept separate from
791/// `record`'s logging call so the line shape is directly unit-testable
792/// without a log-capture harness, and so the caller can reuse one
793/// growth-free buffer across every frame instead of formatting a fresh
794/// `String` per call (see `docs/CODE_STANDARDS.md`'s Instrumentation
795/// conventions — formatting only, no allocation growth on the hot path).
796/// Field order: `n`, `total_us`, `rebuild_us`, `layout_us`, `paint_us`,
797/// `encode_us`, `acquire_us`, `submit_us`, `skipped` (`0`/`1`), `gpu_q`
798/// (`0`/`1`), then — only when `gpu_q=1` — `gpu_total_us`, `gpu_prepass_us`,
799/// `gpu_main_us`, `gpu_composite_us`, `gpu_blit_us`. Microsecond resolution so
800/// a sub-millisecond pass still shows nonzero.
801///
802/// **Unmeasured span:** a reading whose `main` span is unmeasured
803/// ([`GpuPasses::main_is_measured`]) is written as `gpu_q=0` with no `gpu_*_us`
804/// columns, never as `gpu_main_us=0`. That is the existing "no GPU reading"
805/// shape every reader already skips, so no field or token is added to the line.
806///
807/// **Format v4 (2026-09-01):** real GPU time per pass ([`GpuPasses`]) is
808/// appended after `skipped`, additively — every v3 field keeps its name,
809/// meaning and position, so a v3 parser reads a v4 line unchanged and a v4
810/// parser reads a v3 line as `gpu_q=0`. `gpu_q` states whether this frame
811/// carries a GPU reading at all; the five `gpu_*_us` fields are **omitted
812/// entirely** when it is `0`, rather than written as zeros, so a series with no
813/// GPU timing never produces a column of zeros that reads like a measurement.
814///
815/// **Format v3 (2026-07-22):** the single `present_us` field of v2
816/// was split into separate `acquire_us` + `submit_us` fields (no combined field
817/// is kept); `acquire_us + submit_us` equals the old v2 `present_us` for
818/// cross-baseline math. **Format v2 (2026-07-21):** the single
819/// `encode_present_us` field of v1 was split into `encode_us` + `present_us`.
820/// Any harness parsing this line must handle the current field set; see
821/// `benchmarks/PROTOCOL.md`'s format-change note. Compiled only under
822/// `perf-trace` (it bears the `frust-perf raw` prefix).
823#[cfg(feature = "perf-trace")]
824fn format_raw_frame_line(buf: &mut String, n: u64, passes: &FramePasses) {
825 use std::fmt::Write as _;
826 buf.clear();
827 let _ = write!(
828 buf,
829 "{RAW_FRAME_PREFIX} n={n} total_us={} rebuild_us={} layout_us={} paint_us={} \
830 encode_us={} acquire_us={} submit_us={} skipped={} gpu_q={}",
831 passes.total().as_micros(),
832 passes.rebuild.as_micros(),
833 passes.layout.as_micros(),
834 passes.paint.as_micros(),
835 passes.encode.as_micros(),
836 passes.acquire.as_micros(),
837 passes.submit.as_micros(),
838 u8::from(passes.skipped),
839 u8::from(passes.gpu.is_some_and(|gpu| gpu.main_is_measured())),
840 );
841 if let Some(gpu) = passes.gpu.filter(GpuPasses::main_is_measured) {
842 let _ = write!(
843 buf,
844 " gpu_total_us={} gpu_prepass_us={} gpu_main_us={} gpu_composite_us={} \
845 gpu_blit_us={}",
846 gpu.total().as_micros(),
847 gpu.prepass.as_micros(),
848 gpu.main.as_micros(),
849 gpu.composite.as_micros(),
850 gpu.blit.as_micros(),
851 );
852 }
853}
854
855/// Which edge of a benchmark scenario window [`mark_scenario_start`]/
856/// [`mark_scenario_end`] raises. Compiled only under `perf-trace`, like
857/// every other piece of the marker route: a release-lean build carries
858/// neither the queues below nor the `bench-scenario-*` string literals this
859/// maps to.
860#[cfg(feature = "perf-trace")]
861#[derive(Debug, Clone, Copy, PartialEq, Eq)]
862enum MarkerEdge {
863 Start,
864 End,
865}
866
867#[cfg(feature = "perf-trace")]
868impl MarkerEdge {
869 const fn prefix(self) -> &'static str {
870 match self {
871 MarkerEdge::Start => "bench-scenario-start",
872 MarkerEdge::End => "bench-scenario-end",
873 }
874 }
875}
876
877/// One raised-but-not-yet-emitted scenario-window edge: what
878/// [`mark_scenario_start`]/[`mark_scenario_end`] queue and
879/// [`FrameStats::record`] eventually logs, stamped with the frame that
880/// carried it.
881///
882/// Deliberately opaque and `pub(crate)`: the route it travels
883/// ([`take_pending_markers`] → [`stage_markers`]) is this crate's own
884/// plumbing, so nothing outside the crate can read the edge, rewrite the
885/// name, or inject a marker that was never raised — the emitted line's shape
886/// stays this module's business alone. `Box<str>` rather than `String`: a
887/// marker name is never appended to after it is raised.
888#[cfg(feature = "perf-trace")]
889#[derive(Debug, Clone, PartialEq, Eq)]
890pub(crate) struct ScenarioMarker {
891 edge: MarkerEdge,
892 name: Box<str>,
893}
894
895/// The most markers any one leg of the route ([`PENDING`], [`STAGED`],
896/// [`crate::render_split`]'s inbox queue) holds at once.
897///
898/// A leg only grows while markers are raised faster than frames are
899/// recorded, which a benchmark process (two markers per scenario operation,
900/// about one operation per frame) never does for long; a leg that reaches
901/// this cap therefore means something is already wrong — markers raised on a
902/// thread that hands no frame off, or a render thread that has stopped
903/// recording — and the honest response is to bound the memory rather than
904/// accumulate forever. On overflow the **oldest** marker is dropped (a live
905/// window needs the newest edges) and the drop is counted; the count travels
906/// with the batch and [`FrameStats::record`] reports it on the next frame
907/// that carries markers, as its own `frust-perf marker-overflow` line —
908/// never folded into a `bench-scenario-*` line, whose shape is a parsed wire
909/// format (`benchmarks/PROTOCOL.md` §7).
910#[cfg(feature = "perf-trace")]
911pub(crate) const MARKER_QUEUE_CAP: usize = 256;
912
913/// One leg of the marker route: markers in raise order, plus how many were
914/// dropped to keep the leg inside [`MARKER_QUEUE_CAP`].
915///
916/// The drop count rides with the markers ([`Self::absorb`] sums it) rather
917/// than living in a counter of its own, so a drop that happened on the UI
918/// thread is still reported by the render thread that records the frame
919/// those markers landed on.
920#[cfg(feature = "perf-trace")]
921#[derive(Debug, Default)]
922pub(crate) struct MarkerQueue {
923 markers: VecDeque<ScenarioMarker>,
924 dropped: u64,
925}
926
927#[cfg(feature = "perf-trace")]
928impl MarkerQueue {
929 /// An empty queue that has allocated nothing — `const` so the
930 /// thread-locals below initialize with no lazy first-use branch.
931 pub(crate) const fn new() -> Self {
932 Self {
933 markers: VecDeque::new(),
934 dropped: 0,
935 }
936 }
937
938 /// Nothing to carry: no markers **and** no drop count. A queue holding
939 /// only a drop count cannot happen today (a drop happens on a push,
940 /// which leaves the pushed marker behind), but treating the count as
941 /// payload keeps a caller from stranding one behind an emptiness fast
942 /// path.
943 pub(crate) fn is_empty(&self) -> bool {
944 self.markers.is_empty() && self.dropped == 0
945 }
946
947 /// Queue one marker, dropping the oldest if that would exceed
948 /// [`MARKER_QUEUE_CAP`].
949 fn push(&mut self, marker: ScenarioMarker) {
950 if self.markers.len() >= MARKER_QUEUE_CAP {
951 self.markers.pop_front();
952 self.dropped = self.dropped.saturating_add(1);
953 }
954 self.markers.push_back(marker);
955 }
956
957 /// Move `other`'s markers (oldest first) and its drop count into this
958 /// queue, enforcing the cap again on the way in.
959 pub(crate) fn absorb(&mut self, other: MarkerQueue) {
960 self.dropped = self.dropped.saturating_add(other.dropped);
961 for marker in other.markers {
962 self.push(marker);
963 }
964 }
965
966 /// Take everything queued here, leaving this queue empty.
967 pub(crate) fn take(&mut self) -> MarkerQueue {
968 std::mem::take(self)
969 }
970
971 /// Forget everything queued here, drop count included — for a leg whose
972 /// markers no frame will ever name.
973 pub(crate) fn clear(&mut self) {
974 self.markers.clear();
975 self.dropped = 0;
976 }
977}
978
979#[cfg(feature = "perf-trace")]
980thread_local! {
981 /// The markers **this** thread has raised and not yet handed on:
982 /// [`mark_scenario_start`]/[`mark_scenario_end`] push here, nothing else
983 /// does.
984 ///
985 /// Per-thread rather than process-wide, which is what collapses the two
986 /// executors into one rule instead of a shared queue plus a flag
987 /// deciding who owns it:
988 ///
989 /// - **render-thread split** — the UI thread raises markers and is also
990 /// the thread that calls [`RenderSender::send_scene`], which moves this
991 /// queue into the inbox so the markers travel with that handoff. The
992 /// render thread's own copy of this queue is never pushed to, so
993 /// [`FrameStats::record`] draining it over there can steal nothing.
994 /// - **inline** (no render thread) — one thread raises and records, so
995 /// [`FrameStats::record`] drains this queue directly, for the frame it
996 /// is recording right now.
997 ///
998 /// Bounded at [`MARKER_QUEUE_CAP`].
999 ///
1000 /// [`RenderSender::send_scene`]: crate::render_split::RenderSender::send_scene
1001 static PENDING: RefCell<MarkerQueue> = const { RefCell::new(MarkerQueue::new()) };
1002
1003 /// The markers handed to *this* thread for the very next frame it
1004 /// records — the render thread's leg of the route. [`stage_markers`]
1005 /// appends, [`FrameStats::record`] drains. Per-thread for the same reason
1006 /// [`PENDING`] is: the draining thread is by construction the one that
1007 /// took the batch out of the inbox and is about to render and record it,
1008 /// so no lock is needed and no other thread's frame can pick these up by
1009 /// accident. Bounded at [`MARKER_QUEUE_CAP`].
1010 static STAGED: RefCell<MarkerQueue> = const { RefCell::new(MarkerQueue::new()) };
1011}
1012
1013/// Take the calling thread's raised-but-not-handed-on markers, leaving its
1014/// queue empty — [`RenderSender::send_scene`] calls this to move them into
1015/// the inbox beside the scene they belong to, and calls it again (discarding
1016/// the result) on its dead-receiver path, so a queue no frame can ever name
1017/// is not left growing.
1018///
1019/// [`markers_enabled`] first: this runs on every frame of every split shell,
1020/// including in a `perf-trace` build running with `FRUST_TRACE` off, so the
1021/// disabled case must cost one cached bool read and no thread-local access
1022/// at all.
1023///
1024/// [`RenderSender::send_scene`]: crate::render_split::RenderSender::send_scene
1025#[cfg(feature = "perf-trace")]
1026#[inline]
1027pub(crate) fn take_pending_markers() -> MarkerQueue {
1028 if !markers_enabled() {
1029 return MarkerQueue::new();
1030 }
1031 PENDING.with(|pending| pending.borrow_mut().take())
1032}
1033
1034/// Hand markers to the calling thread's next recorded frame, appending to
1035/// whatever is already staged there (see [`STAGED`]). Called by the render
1036/// thread as it takes a scene out of the inbox; the frame it records next is
1037/// the one that scene belongs to.
1038///
1039/// **Accepted best-effort edge**: a batch taken but then *not* rendered (the
1040/// render loop's [`RenderPhase`] cannot render it — a paused or
1041/// surface-less phase) records no frame, so its markers stay staged and
1042/// attach to the next frame this thread does record. A scenario window
1043/// bracketed across such a gap is therefore attributed to the first frame
1044/// that really rendered after it, which is the closest honest answer
1045/// available without inventing a frame that was never drawn.
1046///
1047/// [`RenderPhase`]: crate::render_split::RenderPhase
1048#[cfg(feature = "perf-trace")]
1049pub(crate) fn stage_markers(markers: MarkerQueue) {
1050 if markers.is_empty() {
1051 return;
1052 }
1053 STAGED.with(|staged| staged.borrow_mut().absorb(markers));
1054}
1055
1056/// Take this thread's staged markers, leaving the slot empty. `is_empty`
1057/// fast path first: the overwhelmingly common frame carries no marker and
1058/// must not allocate.
1059#[cfg(feature = "perf-trace")]
1060fn take_staged_markers() -> MarkerQueue {
1061 STAGED.with(|staged| {
1062 let mut staged = staged.borrow_mut();
1063 if staged.is_empty() {
1064 MarkerQueue::new()
1065 } else {
1066 staged.take()
1067 }
1068 })
1069}
1070
1071#[cfg(all(test, feature = "perf-trace"))]
1072thread_local! {
1073 /// Test-only override of [`markers_enabled`] for the **calling** thread:
1074 /// [`enabled`] caches a process environment read in a `OnceLock` and can
1075 /// never be flipped back, so a test that needs a marker actually queued
1076 /// sets this instead of relaxing production gating.
1077 ///
1078 /// Per-thread like the queues it gates, which is what lets marker tests
1079 /// run beside every other test in the process with no lock at all: a
1080 /// two-thread test arms it on each thread that raises markers (see
1081 /// [`marker_test_guard`]).
1082 static MARKERS_FORCE_ENABLED: Cell<bool> = const { Cell::new(false) };
1083}
1084
1085/// The RAII handle [`marker_test_guard`] returns: disarms the force switch
1086/// and empties this thread's queues on drop, so one test's leftovers can
1087/// never leak into the next test that runs on the same thread.
1088#[cfg(all(test, feature = "perf-trace"))]
1089pub(crate) struct MarkerTestGuard {
1090 _private: (),
1091}
1092
1093#[cfg(all(test, feature = "perf-trace"))]
1094impl Drop for MarkerTestGuard {
1095 fn drop(&mut self) {
1096 MARKERS_FORCE_ENABLED.with(|forced| forced.set(false));
1097 reset_marker_route();
1098 }
1099}
1100
1101/// Empty both of the calling thread's marker queues.
1102#[cfg(all(test, feature = "perf-trace"))]
1103fn reset_marker_route() {
1104 PENDING.with(|pending| pending.borrow_mut().clear());
1105 STAGED.with(|staged| staged.borrow_mut().clear());
1106}
1107
1108/// Start this thread's use of the marker route from a pristine queue pair
1109/// and (when `force_enabled`) with markers queueing as though `FRUST_TRACE`
1110/// were set. `pub(crate)` because `render_split`'s channel tests drive the
1111/// same route from the other end.
1112///
1113/// There is no lock: every piece of state this arms belongs to the calling
1114/// thread, so two marker tests running in parallel cannot see each other's
1115/// queues. A test that spawns its own UI thread must call this on **that**
1116/// thread too — the force switch no more crosses a thread boundary than a
1117/// queue does.
1118#[cfg(all(test, feature = "perf-trace"))]
1119pub(crate) fn marker_test_guard(force_enabled: bool) -> MarkerTestGuard {
1120 reset_marker_route();
1121 MARKERS_FORCE_ENABLED.with(|forced| forced.set(force_enabled));
1122 MarkerTestGuard { _private: () }
1123}
1124
1125/// Whether a raised marker is queued at all. The one-dial gate
1126/// ([`enabled`], not [`enabled`]-and-[`raw_enabled`]): a marker is emitted
1127/// by [`FrameStats::record`] itself, which runs whenever perf is on, so
1128/// tying markers to the raw-export dial would mean a scenario window that
1129/// exists in one capture and silently not in another.
1130#[cfg(feature = "perf-trace")]
1131#[inline]
1132fn markers_enabled() -> bool {
1133 #[cfg(test)]
1134 if MARKERS_FORCE_ENABLED.with(Cell::get) {
1135 return true;
1136 }
1137 enabled()
1138}
1139
1140/// Queue one marker onto the calling thread's [`PENDING`] queue — the single
1141/// body behind both [`mark_scenario_start`] and [`mark_scenario_end`], so the
1142/// two edges can never drift apart.
1143#[cfg(feature = "perf-trace")]
1144fn push_marker(edge: MarkerEdge, name: &str) {
1145 if !markers_enabled() {
1146 return;
1147 }
1148 PENDING.with(|pending| {
1149 pending.borrow_mut().push(ScenarioMarker {
1150 edge,
1151 name: name.into(),
1152 });
1153 });
1154}
1155
1156/// Formats one scenario-marker line — separated from the emission call for
1157/// the same directly-unit-testable reason [`format_raw_frame_line`] is.
1158/// Compiled only under `perf-trace` (it emits the `bench-scenario-*`
1159/// prefixes).
1160///
1161/// **Shape (2026-09-06):** `<prefix> n=<frame> <name>`, where `frame` is the
1162/// 1-indexed counter of the frame that carried this marker through the
1163/// pipeline — the very same counter [`format_raw_frame_line`] writes as a
1164/// raw line's own `n=`, because both are stamped by the same
1165/// [`FrameStats::record`] call. There is no config toggle back to the older
1166/// name-only `<prefix> <name>` shape; a series captured before this change
1167/// is still name-only and a harness parsing it falls back to log-position
1168/// bracketing (`benchmarks/harness/stats.py`'s `slice_scenario`).
1169#[cfg(feature = "perf-trace")]
1170fn format_scenario_marker(edge: MarkerEdge, name: &str, frame: u64) -> String {
1171 format!("{} n={frame} {name}", edge.prefix())
1172}
1173
1174/// Prefix of the queue-overflow notice (see [`MARKER_QUEUE_CAP`]).
1175/// Deliberately neither the `frust-perf raw`/`frust-perf op`/`frust-perf
1176/// plugin` prefix nor a `bench-scenario-*` one, so a harness scanning the
1177/// same log stream (`benchmarks/harness/stats.py`) reads this as an
1178/// unrelated line rather than as a malformed record of a shape it parses.
1179#[cfg(feature = "perf-trace")]
1180const MARKER_OVERFLOW_PREFIX: &str = "frust-perf marker-overflow";
1181
1182/// Formats the notice that `dropped` markers were discarded to keep a route
1183/// leg inside [`MARKER_QUEUE_CAP`], stamped with the frame whose markers
1184/// carried the count in. Kept out of the `bench-scenario-*` line itself
1185/// because that line's `<prefix> n=<u64> <name>` shape is a parsed wire
1186/// format: `stats.py`'s `parse_marker_line` reads everything after the `n=`
1187/// token as the scenario name, so an appended field would rename the window
1188/// rather than annotate it.
1189#[cfg(feature = "perf-trace")]
1190fn format_marker_overflow_line(frame: u64, dropped: u64) -> String {
1191 format!("{MARKER_OVERFLOW_PREFIX} n={frame} dropped={dropped}")
1192}
1193
1194/// Raise a `bench-scenario-start` marker for `name` — the opening edge of a
1195/// benchmark scenario window, so an external harness can slice the
1196/// per-frame `frust-perf raw` series into named scenarios without holding a
1197/// [`FrameStats`] handle itself (a marker is a scenario-boundary event, not
1198/// a per-frame one, hence a free function rather than a method).
1199///
1200/// # What the emitted `n` means
1201///
1202/// Raising a marker does **not** log it. The marker is queued, travels with
1203/// the frame the calling build hands off, and is logged by
1204/// [`FrameStats::record`] as `bench-scenario-start n=<frame> <name>` where
1205/// `<frame>` is the number of the frame that actually carried it through
1206/// the pipeline — immediately ahead of that frame's own `frust-perf raw`
1207/// line. Nothing here guesses a frame number across a thread boundary,
1208/// which is the whole point: on the render-thread split the UI thread that
1209/// raises a marker cannot know whether the render thread has recorded the
1210/// previous frame yet.
1211///
1212/// The window is **half-open**. `start` is raised in the build that also
1213/// applies the operation being measured, so `start n=k` names the window's
1214/// first frame; `end` is raised in the *next* build (the S3 convention —
1215/// `benchmarks/frust_bench/src/scenarios/s3_table.rs`), so `end n=k+1`
1216/// names the first frame *after* the window. A harness attributes the
1217/// frames with `start_n <= n < end_n` to the window — one frame, for the S3
1218/// shape (see `benchmarks/PROTOCOL.md` §7).
1219///
1220/// Two consequences worth knowing:
1221///
1222/// - Under the channel's depth-1 latest-wins slot, a build whose scene is
1223/// replaced before the render thread takes it never becomes a frame of
1224/// its own; its markers ride the frame that superseded it — the frame
1225/// that actually drew that build's result. If a window's `start` and
1226/// `end` both land on that one frame, the half-open window is empty,
1227/// which is the honest answer: the operation's own frame was dropped.
1228/// - **Raise a marker on the thread that produces frames.** A marker is
1229/// queued on the calling thread and leaves it only when that same thread
1230/// hands a frame across the render channel (the split's UI thread) or
1231/// records one itself (the inline executor). Raised anywhere else — a
1232/// `spawn_blocking` pool thread, a plugin callback thread — it belongs to
1233/// a queue no frame will ever be recorded from, and is therefore never
1234/// emitted. That is the price of never guessing a frame number across a
1235/// thread boundary: a marker attached to a frame the raiser is not
1236/// producing would be exactly the cross-thread guess this route exists to
1237/// remove. A scenario measuring off-thread work brackets it from the
1238/// build that shows the result, not from inside the worker.
1239///
1240/// A no-op unless [`enabled`] is `true`; unlike the raw per-frame line it
1241/// does **not** additionally require [`raw_enabled`], so a scenario window
1242/// is present in every perf-enabled capture. A build without the
1243/// `perf-trace` feature compiles the whole route out, leaving this an empty
1244/// function.
1245#[cfg_attr(not(feature = "perf-trace"), allow(unused_variables))]
1246pub fn mark_scenario_start(name: &str) {
1247 #[cfg(feature = "perf-trace")]
1248 push_marker(MarkerEdge::Start, name);
1249}
1250
1251/// Raise a `bench-scenario-end` marker — the closing edge of the window
1252/// [`mark_scenario_start`] opened; see its docs, which cover the gating, the
1253/// emitted `n`, and the half-open `[start_n, end_n)` rule identically. In
1254/// the S3 convention this is raised in the build *after* the measured one,
1255/// so its `n` is one past the window's last frame.
1256#[cfg_attr(not(feature = "perf-trace"), allow(unused_variables))]
1257pub fn mark_scenario_end(name: &str) {
1258 #[cfg(feature = "perf-trace")]
1259 push_marker(MarkerEdge::End, name);
1260}
1261
1262/// Emit one already-formatted benchmark trace line into the same
1263/// `log::info!` stream the per-frame `frust-perf raw` lines and the
1264/// `bench-scenario-*` markers land in.
1265///
1266/// This is the frust counterpart to the Flutter bench's `benchEmit`
1267/// (`benchmarks/flutter_bench/lib/bench/perf.dart`): a benchmark scenario that
1268/// records a per-operation measurement (e.g. S8's
1269/// `frust-perf plugin op=write type=bool n=0 us=12` per-op latency lines) hands
1270/// this an already-formatted single line, which the harness parses alongside
1271/// the frame series. Centralizing every bench line behind one gated sink keeps
1272/// per-op emission on the same two-dial switch the markers use — a no-op unless
1273/// both [`enabled`] and [`raw_enabled`] are `true`.
1274#[cfg_attr(not(feature = "perf-trace"), allow(unused_variables))]
1275pub fn bench_emit(line: &str) {
1276 #[cfg(feature = "perf-trace")]
1277 if enabled() && raw_enabled() {
1278 log::info!("{line}");
1279 }
1280}
1281
1282// ---------------------------------------------------------------------
1283// StartupSpans
1284// ---------------------------------------------------------------------
1285
1286/// Startup-span name: native library load (process/JNI load, or the C-ABI
1287/// equivalent on iOS).
1288pub const SPAN_NATIVE_LIB_LOAD: &str = "native_lib_load";
1289/// Startup-span name: entry into the shell's init function (`nativeInit` /
1290/// `frust_init` / the desktop app-construction entry point).
1291pub const SPAN_INIT_ENTRY: &str = "init_entry";
1292/// Startup-span name: the wgpu adapter is acquired.
1293pub const SPAN_ADAPTER_READY: &str = "adapter_ready";
1294/// Startup-span name: the wgpu logical device is acquired.
1295pub const SPAN_DEVICE_READY: &str = "device_ready";
1296/// Startup-span name: the vello renderer (and surface) are ready to
1297/// present.
1298pub const SPAN_RENDERER_READY: &str = "renderer_ready";
1299/// Startup-span name: a persisted GPU
1300/// pipeline cache blob was restored before surface creation — its *presence* in
1301/// the startup line is the warm-start (cache-**hit**) signal, its *absence* the
1302/// cold-start (cache-**miss**) one, so a slow first frame can be attributed to
1303/// shader-pipeline compilation vs a warm cache. Recorded only on a hit, right
1304/// before the surface (and thus the pipeline) is built.
1305pub const SPAN_PIPELINE_CACHE_RESTORED: &str = "pipeline_cache_restored";
1306/// Startup-span name: the app's first `rebuild` pass has completed.
1307pub const SPAN_FIRST_REBUILD_DONE: &str = "first_rebuild_done";
1308/// Startup-span name: the app's first
1309/// frame's GPU/CPU **encode** has completed — the boundary between the first
1310/// frame's paint/encode work and its swapchain-acquire (present) wait, so a
1311/// first-frame outlier (a 3646ms-class span) decomposes into encode vs present
1312/// exactly as the per-frame [`FramePasses`] split does.
1313pub const SPAN_FIRST_ENCODE_DONE: &str = "first_encode_done";
1314/// Startup-span name: the app's first frame has been presented to the
1315/// surface.
1316pub const SPAN_FIRST_FRAME_PRESENTED: &str = "first_frame_presented";
1317
1318/// Startup-span name: how much of the first frame's `submit` went on resolving
1319/// pipeline variants the cache did not already hold (waiting on the warm-up
1320/// worker, or building inline). A **duration**, not an offset from the epoch —
1321/// see [`StartupSpans::record_duration`].
1322pub const SPAN_FIRST_SUBMIT_PIPELINE_WAIT: &str = "first_submit_pipeline_wait";
1323/// Startup-span name: the first frame's engine encode, less the pipeline wait
1324/// above. A duration (see [`StartupSpans::record_duration`]).
1325pub const SPAN_FIRST_SUBMIT_ENCODE: &str = "first_submit_encode";
1326/// Startup-span name: the first frame's `Queue::submit` plus the present. A
1327/// duration (see [`StartupSpans::record_duration`]).
1328pub const SPAN_FIRST_SUBMIT_QUEUE: &str = "first_submit_queue";
1329
1330/// Startup sub-step names. Every one is a **duration** in milliseconds (recorded
1331/// through [`StartupSpans::record_duration`]), not an offset from the epoch,
1332/// and each is printed right after `adapter_ready`. They split the gap between
1333/// `pipeline_cache_restored` (Android) / the render-thread start (iOS) and
1334/// `adapter_ready`: `engine_surface_ms`, `cache_seed_ms`, `caps_probe_ms`,
1335/// `shader_modules_ms`, `warm_up_start_ms`, `engine_rest_ms` and
1336/// `install_rest_ms` are disjoint and sum to the renderer's first install;
1337/// `device_create_ms` is a part of `engine_surface_ms`, reported on its own.
1338/// `font_system_ms` and `font_app_sync_ms` split `TextContext::new` (which runs
1339/// on the font-preinit thread, so it overlaps the above rather than adding to it).
1340///
1341/// Surface creation, device creation, capability gate and swapchain configure.
1342pub const SPAN_ENGINE_SURFACE_MS: &str = "engine_surface_ms";
1343/// The `ensure_device` call (a part of [`SPAN_ENGINE_SURFACE_MS`]).
1344pub const SPAN_DEVICE_CREATE_MS: &str = "device_create_ms";
1345/// `create_pipeline_cache`, the driver-cache seed.
1346pub const SPAN_CACHE_SEED_MS: &str = "cache_seed_ms";
1347/// `TierCaps::probe`.
1348pub const SPAN_CAPS_PROBE_MS: &str = "caps_probe_ms";
1349/// The engine's shader-module registration (`register_family`).
1350pub const SPAN_SHADER_MODULES_MS: &str = "shader_modules_ms";
1351/// The engine's pipeline-cache construction plus warm-up enqueue/spawn.
1352pub const SPAN_WARM_UP_START_MS: &str = "warm_up_start_ms";
1353/// The rest of `EngineRenderer::new`.
1354pub const SPAN_ENGINE_REST_MS: &str = "engine_rest_ms";
1355/// The rest of the renderer install (timestamp ring, shader quad pass, state
1356/// adoption). The shell-side remainder is a separate key,
1357/// [`SPAN_RENDER_THREAD_START_MS`].
1358pub const SPAN_INSTALL_REST_MS: &str = "install_rest_ms";
1359/// iOS only: the time between `init_entry` and the renderer install starting
1360/// (render-thread spawn and scheduling), derived as the `init_entry` to
1361/// `adapter_ready` gap less every install sub-step above.
1362pub const SPAN_RENDER_THREAD_START_MS: &str = "render_thread_start_ms";
1363/// `parley::FontContext::new` inside `TextContext::new`.
1364pub const SPAN_FONT_SYSTEM_MS: &str = "font_system_ms";
1365/// `sync_app_fonts` inside `TextContext::new` (about 0 for an app that
1366/// registers no fonts).
1367pub const SPAN_FONT_APP_SYNC_MS: &str = "font_app_sync_ms";
1368
1369/// The disjoint sub-step durations of one renderer install, in the plain
1370/// `Duration`s a shell copies out of `frust-render`'s `InstallTimings` (this
1371/// crate does not depend on `frust-render`). See the `SPAN_*_MS` consts.
1372#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1373pub struct InstallSteps {
1374 pub engine_surface: Duration,
1375 pub device_create: Duration,
1376 pub cache_seed: Duration,
1377 pub caps_probe: Duration,
1378 pub shader_modules: Duration,
1379 pub warm_up_start: Duration,
1380 pub engine_rest: Duration,
1381 pub install_rest: Duration,
1382}
1383
1384impl InstallSteps {
1385 /// The whole install: the disjoint steps (`device_create` is inside
1386 /// `engine_surface`).
1387 pub fn total(&self) -> Duration {
1388 self.engine_surface
1389 + self.cache_seed
1390 + self.caps_probe
1391 + self.shader_modules
1392 + self.warm_up_start
1393 + self.engine_rest
1394 + self.install_rest
1395 }
1396}
1397
1398/// A monotonic clock reading, injectable so [`StartupSpans`]'s deltas are
1399/// deterministically testable without a real clock. Only differences
1400/// between successive readings are meaningful — the absolute value has no
1401/// defined epoch.
1402pub trait Clock {
1403 fn now(&mut self) -> Duration;
1404}
1405
1406/// Any `FnMut() -> Duration` closure is a [`Clock`] — the lighter-weight
1407/// option for a one-off test fake (see the module docs' "injectable clock"
1408/// note).
1409impl<F: FnMut() -> Duration> Clock for F {
1410 fn now(&mut self) -> Duration {
1411 self()
1412 }
1413}
1414
1415/// The production [`Clock`]: wraps [`std::time::Instant`], monotonic for
1416/// the lifetime of the process.
1417#[derive(Debug)]
1418pub struct SystemClock {
1419 start: Instant,
1420}
1421
1422impl SystemClock {
1423 pub fn new() -> Self {
1424 Self {
1425 start: Instant::now(),
1426 }
1427 }
1428}
1429
1430impl Default for SystemClock {
1431 fn default() -> Self {
1432 Self::new()
1433 }
1434}
1435
1436impl Clock for SystemClock {
1437 fn now(&mut self) -> Duration {
1438 self.start.elapsed()
1439 }
1440}
1441
1442/// Named monotonic timestamps from a `begin()` epoch —
1443/// a shell records one named span at each startup milestone (see the
1444/// `SPAN_*` consts), then calls [`Self::emit_log`] once for a single
1445/// summary line.
1446pub struct StartupSpans<C: Clock = SystemClock> {
1447 enabled: bool,
1448 clock: C,
1449 begin: Duration,
1450 spans: Vec<(&'static str, Duration)>,
1451}
1452
1453impl StartupSpans<SystemClock> {
1454 /// Begin a span recorder honoring the process-wide [`enabled`] switch,
1455 /// using the real system clock — what every shell constructs.
1456 pub fn begin() -> Self {
1457 Self::begin_with_enabled(SystemClock::new(), enabled())
1458 }
1459}
1460
1461impl<C: Clock> StartupSpans<C> {
1462 /// Test/advanced seam: begin with an explicit clock and enabled flag,
1463 /// bypassing [`enabled`]'s cache (see [`FrameStats::new_enabled`]'s docs
1464 /// for why).
1465 pub fn begin_with_enabled(mut clock: C, is_enabled: bool) -> Self {
1466 let begin = clock.now();
1467 Self {
1468 enabled: is_enabled,
1469 clock,
1470 begin,
1471 spans: Vec::new(),
1472 }
1473 }
1474
1475 /// Record `name` at the current clock reading, as a delta from
1476 /// [`Self::begin`]'s epoch. A no-op (no clock read, no allocation) when
1477 /// disabled.
1478 pub fn record(&mut self, name: &'static str) {
1479 if !self.enabled {
1480 return;
1481 }
1482 let now = self.clock.now();
1483 self.spans.push((name, now.saturating_sub(self.begin)));
1484 }
1485
1486 /// Record `name` with an explicit `duration`, for a span measured
1487 /// elsewhere (the first frame's submit sub-spans, timed inside
1488 /// `frust-render`, which cannot see this recorder).
1489 ///
1490 /// Unlike [`Self::record`] the value is NOT an offset from the epoch: the
1491 /// line prints it as-is, so `first_submit_*` fields read as milliseconds
1492 /// spent, and the three sum to about the first frame's `submit` span. They
1493 /// are inserted between `first_encode_done` and `first_frame_presented`.
1494 /// A no-op when disabled.
1495 pub fn record_duration(&mut self, name: &'static str, duration: Duration) {
1496 if !self.enabled {
1497 return;
1498 }
1499 self.spans.push((name, duration));
1500 }
1501
1502 /// The recorded offset of the first span called `name`, if any.
1503 pub fn offset_of(&self, name: &str) -> Option<Duration> {
1504 self.spans.iter().find(|(n, _)| *n == name).map(|(_, d)| *d)
1505 }
1506
1507 /// Record every install sub-step as a duration. With `start_ref`, also
1508 /// record [`SPAN_RENDER_THREAD_START_MS`]: the `start_ref` to
1509 /// `adapter_ready` gap less the install's own total (call after
1510 /// `adapter_ready` is recorded; skipped when either span is missing).
1511 pub fn record_install_steps(&mut self, steps: &InstallSteps, start_ref: Option<&str>) {
1512 if !self.enabled {
1513 return;
1514 }
1515 self.record_duration(SPAN_ENGINE_SURFACE_MS, steps.engine_surface);
1516 self.record_duration(SPAN_DEVICE_CREATE_MS, steps.device_create);
1517 self.record_duration(SPAN_CACHE_SEED_MS, steps.cache_seed);
1518 self.record_duration(SPAN_CAPS_PROBE_MS, steps.caps_probe);
1519 self.record_duration(SPAN_SHADER_MODULES_MS, steps.shader_modules);
1520 self.record_duration(SPAN_WARM_UP_START_MS, steps.warm_up_start);
1521 self.record_duration(SPAN_ENGINE_REST_MS, steps.engine_rest);
1522 self.record_duration(SPAN_INSTALL_REST_MS, steps.install_rest);
1523 if let Some(start) = start_ref.and_then(|r| self.offset_of(r))
1524 && let Some(ready) = self.offset_of(SPAN_ADAPTER_READY)
1525 {
1526 let gap = ready.saturating_sub(start);
1527 self.record_duration(
1528 SPAN_RENDER_THREAD_START_MS,
1529 gap.saturating_sub(steps.total()),
1530 );
1531 }
1532 }
1533
1534 /// Record the two `TextContext::new` halves as durations.
1535 pub fn record_font_steps(&mut self, font_system: Duration, app_sync: Duration) {
1536 self.record_duration(SPAN_FONT_SYSTEM_MS, font_system);
1537 self.record_duration(SPAN_FONT_APP_SYNC_MS, app_sync);
1538 }
1539
1540 /// The recorded `(name, delta)` pairs in insertion order.
1541 pub fn spans(&self) -> &[(&'static str, Duration)] {
1542 &self.spans
1543 }
1544
1545 /// Emit one `frust-perf startup ...` line via `log::info!` containing
1546 /// every recorded span's name and millisecond delta, in insertion
1547 /// order. A no-op when disabled or when nothing has been recorded.
1548 pub fn emit_log(&self) {
1549 // The `frust-perf startup` line is a gated string literal; a
1550 // release-lean (feature-off) build compiles the body away entirely
1551 // (and `enabled` is a `false` constant there anyway). Written as a
1552 // positive guard rather than an early return so the feature-off body
1553 // is simply empty, with no dangling `return`.
1554 if self.enabled && !self.spans.is_empty() {
1555 #[cfg(feature = "perf-trace")]
1556 {
1557 if let Some(line) = self.format_line() {
1558 log::info!("{line}");
1559 }
1560 }
1561 }
1562 }
1563
1564 /// The `frust-perf startup ...` line [`Self::emit_log`] logs, or `None`
1565 /// when disabled or nothing was recorded.
1566 #[cfg(feature = "perf-trace")]
1567 fn format_line(&self) -> Option<String> {
1568 if !self.enabled || self.spans.is_empty() {
1569 return None;
1570 }
1571 let mut line = String::from("frust-perf startup");
1572 for (name, delta) in &self.spans {
1573 line.push_str(&format!(" {name}={}ms", delta.as_millis()));
1574 }
1575 Some(line)
1576 }
1577}
1578
1579#[cfg(test)]
1580mod tests {
1581 use super::*;
1582
1583 #[test]
1584 fn substep_durations_follow_adapter_ready_and_keep_the_existing_spelling() {
1585 let mut spans = StartupSpans::begin_with_enabled(|| Duration::from_millis(500), true);
1586 spans.record(SPAN_ADAPTER_READY);
1587 spans.record_duration(SPAN_ENGINE_SURFACE_MS, Duration::from_millis(30));
1588 spans.record_duration(SPAN_DEVICE_CREATE_MS, Duration::from_millis(12));
1589 spans.record_duration(SPAN_CACHE_SEED_MS, Duration::from_millis(1));
1590 spans.record_duration(SPAN_CAPS_PROBE_MS, Duration::from_millis(2));
1591 spans.record_duration(SPAN_SHADER_MODULES_MS, Duration::from_millis(3));
1592 spans.record_duration(SPAN_WARM_UP_START_MS, Duration::from_millis(4));
1593 spans.record_duration(SPAN_ENGINE_REST_MS, Duration::from_millis(5));
1594 spans.record_duration(SPAN_INSTALL_REST_MS, Duration::from_millis(6));
1595 spans.record_duration(SPAN_FONT_SYSTEM_MS, Duration::from_millis(7));
1596 spans.record_duration(SPAN_FONT_APP_SYNC_MS, Duration::from_millis(0));
1597 spans.record(SPAN_FIRST_FRAME_PRESENTED);
1598 let names: Vec<&str> = spans.spans().iter().map(|(n, _)| *n).collect();
1599 assert_eq!(
1600 names,
1601 [
1602 "adapter_ready",
1603 "engine_surface_ms",
1604 "device_create_ms",
1605 "cache_seed_ms",
1606 "caps_probe_ms",
1607 "shader_modules_ms",
1608 "warm_up_start_ms",
1609 "engine_rest_ms",
1610 "install_rest_ms",
1611 "font_system_ms",
1612 "font_app_sync_ms",
1613 "first_frame_presented",
1614 ]
1615 );
1616 // The existing keys keep their exact spelling and offset semantics.
1617 assert_eq!(SPAN_ADAPTER_READY, "adapter_ready");
1618 assert_eq!(SPAN_FIRST_FRAME_PRESENTED, "first_frame_presented");
1619 assert_eq!(spans.spans()[0].1, Duration::ZERO);
1620 assert_eq!(spans.spans()[1].1, Duration::from_millis(30));
1621 }
1622
1623 #[test]
1624 fn install_steps_record_in_order_and_derive_the_render_thread_start() {
1625 let mut now = 0u64;
1626 let clock = move || {
1627 let t = Duration::from_millis(now);
1628 now += 100;
1629 t
1630 };
1631 // begin reads 0; init_entry 100; adapter_ready 200.
1632 let mut spans = StartupSpans::begin_with_enabled(clock, true);
1633 spans.record(SPAN_INIT_ENTRY);
1634 spans.record(SPAN_ADAPTER_READY);
1635 let ms = Duration::from_millis;
1636 let steps = InstallSteps {
1637 engine_surface: ms(30),
1638 device_create: ms(12),
1639 cache_seed: ms(1),
1640 caps_probe: ms(2),
1641 shader_modules: ms(3),
1642 warm_up_start: ms(4),
1643 engine_rest: ms(5),
1644 install_rest: ms(6),
1645 };
1646 assert_eq!(steps.total(), ms(51));
1647 spans.record_install_steps(&steps, Some(SPAN_INIT_ENTRY));
1648 let last = spans.spans().last().copied().unwrap();
1649 assert_eq!(last, (SPAN_RENDER_THREAD_START_MS, ms(100 - 51)));
1650 assert_eq!(spans.spans().len(), 2 + 8 + 1);
1651
1652 let mut off = StartupSpans::begin_with_enabled(|| Duration::ZERO, false);
1653 off.record_install_steps(&steps, Some(SPAN_INIT_ENTRY));
1654 assert!(off.spans().is_empty());
1655 }
1656
1657 #[cfg(feature = "perf-trace")]
1658 #[test]
1659 fn emit_log_prints_the_substeps_after_adapter_ready() {
1660 let mut spans = StartupSpans::begin_with_enabled(|| Duration::from_millis(500), true);
1661 spans.record(SPAN_ADAPTER_READY);
1662 spans.record_duration(SPAN_ENGINE_SURFACE_MS, Duration::from_millis(30));
1663 spans.record_duration(SPAN_INSTALL_REST_MS, Duration::from_millis(6));
1664 spans.record(SPAN_FIRST_FRAME_PRESENTED);
1665 let line = spans.format_line().expect("enabled and non-empty");
1666 assert_eq!(
1667 line,
1668 "frust-perf startup adapter_ready=0ms engine_surface_ms=30ms install_rest_ms=6ms \
1669 first_frame_presented=0ms"
1670 );
1671 }
1672
1673 #[test]
1674 fn record_duration_pushes_the_value_unshifted_and_is_inert_when_disabled() {
1675 let mut spans = StartupSpans::begin_with_enabled(|| Duration::from_millis(500), true);
1676 spans.record_duration(SPAN_FIRST_SUBMIT_QUEUE, Duration::from_millis(7));
1677 assert_eq!(
1678 spans.spans(),
1679 &[(SPAN_FIRST_SUBMIT_QUEUE, Duration::from_millis(7))]
1680 );
1681 let mut off = StartupSpans::begin_with_enabled(|| Duration::ZERO, false);
1682 off.record_duration(SPAN_FIRST_SUBMIT_ENCODE, Duration::from_millis(1));
1683 assert!(off.spans().is_empty());
1684 }
1685
1686 // ---------------------------------------------------------------
1687 // trace_switch (pure, directly testable — enabled()'s OnceLock cache
1688 // is deliberately NOT re-tested here, see enabled()'s docs). The
1689 // `trace_switch` decision only exists under `perf-trace`, so these run
1690 // in the feature-on configuration only.
1691 // ---------------------------------------------------------------
1692
1693 #[cfg(feature = "perf-trace")]
1694 #[test]
1695 fn trace_switch_off_when_neither_set() {
1696 assert!(!trace_switch(None, None));
1697 }
1698
1699 #[cfg(feature = "perf-trace")]
1700 #[test]
1701 fn trace_switch_on_when_compile_time_set_non_zero() {
1702 assert!(trace_switch(Some("1"), None));
1703 }
1704
1705 #[cfg(feature = "perf-trace")]
1706 #[test]
1707 fn trace_switch_on_when_runtime_set_non_zero() {
1708 assert!(trace_switch(None, Some("1")));
1709 }
1710
1711 #[cfg(feature = "perf-trace")]
1712 #[test]
1713 fn trace_switch_off_when_either_is_literal_zero_and_other_unset() {
1714 assert!(!trace_switch(Some("0"), None));
1715 assert!(!trace_switch(None, Some("0")));
1716 }
1717
1718 #[cfg(feature = "perf-trace")]
1719 #[test]
1720 fn trace_switch_on_when_either_source_wins() {
1721 // Compile-time "0" (effectively off) but runtime "1": still on.
1722 assert!(trace_switch(Some("0"), Some("1")));
1723 assert!(trace_switch(Some("1"), Some("0")));
1724 }
1725
1726 // ---------------------------------------------------------------
1727 // Compile-out switch (perf-trace off): the runtime dial collapses to a
1728 // `false` constant and the whole public perf API stays callable + inert.
1729 // ---------------------------------------------------------------
1730
1731 #[cfg(not(feature = "perf-trace"))]
1732 #[test]
1733 fn enabled_and_raw_enabled_are_const_false_without_feature() {
1734 assert!(!enabled(), "perf-trace off ⇒ enabled() is a false constant");
1735 assert!(
1736 !raw_enabled(),
1737 "perf-trace off ⇒ raw_enabled() is a false constant"
1738 );
1739 }
1740
1741 #[cfg(not(feature = "perf-trace"))]
1742 #[test]
1743 fn disabled_build_public_api_is_callable_and_inert() {
1744 // Every public perf entry point still exists and is safe to call in a
1745 // release-lean build — it just records/emits nothing (no shell call
1746 // site changes between the two configurations).
1747 let mut stats = FrameStats::new();
1748 stats.record(passes(20, 5, 5, 2));
1749 assert_eq!(stats.total_frames(), 0, "feature-off new() is disabled");
1750 assert_eq!(stats.summary().frame_count, 0);
1751 assert!(!stats.should_emit());
1752 stats.emit_log(); // no-op, must not panic
1753
1754 let mut spans = StartupSpans::begin();
1755 spans.record(SPAN_INIT_ENTRY);
1756 assert!(spans.spans().is_empty(), "feature-off begin() is disabled");
1757 spans.emit_log(); // no-op, must not panic
1758
1759 // Free-function emitters are inert no-ops (no strings compiled in).
1760 mark_scenario_start("smoke");
1761 mark_scenario_end("smoke");
1762 bench_emit("smoke op=write");
1763 }
1764
1765 // ---------------------------------------------------------------
1766 // nearest_rank_percentile
1767 // ---------------------------------------------------------------
1768
1769 #[test]
1770 fn percentile_known_distribution_1_to_100ms() {
1771 // A sorted sample of 1ms..=100ms (n = 100): nearest-rank with
1772 // ceil(p*n/100) 1-indexed rank means p50 -> rank 50 -> value 50ms;
1773 // p95 -> rank 95 -> value 95ms; p99 -> rank 99 -> value 99ms.
1774 let sorted: Vec<Duration> = (1..=100).map(Duration::from_millis).collect();
1775 assert_eq!(
1776 nearest_rank_percentile(&sorted, 50),
1777 Duration::from_millis(50)
1778 );
1779 assert_eq!(
1780 nearest_rank_percentile(&sorted, 95),
1781 Duration::from_millis(95)
1782 );
1783 assert_eq!(
1784 nearest_rank_percentile(&sorted, 99),
1785 Duration::from_millis(99)
1786 );
1787 }
1788
1789 #[test]
1790 fn percentile_small_sample_rounds_up_rank() {
1791 // n = 4: rank(50) = ceil(200/100) = 2 -> index 1 -> value 2ms.
1792 // rank(95) = ceil(380/100) = 4 -> index 3 -> value 4ms.
1793 let sorted: Vec<Duration> = (1..=4).map(Duration::from_millis).collect();
1794 assert_eq!(
1795 nearest_rank_percentile(&sorted, 50),
1796 Duration::from_millis(2)
1797 );
1798 assert_eq!(
1799 nearest_rank_percentile(&sorted, 95),
1800 Duration::from_millis(4)
1801 );
1802 }
1803
1804 #[test]
1805 fn percentile_single_value_returns_it_for_every_percentile() {
1806 let sorted = [Duration::from_millis(42)];
1807 assert_eq!(
1808 nearest_rank_percentile(&sorted, 50),
1809 Duration::from_millis(42)
1810 );
1811 assert_eq!(
1812 nearest_rank_percentile(&sorted, 99),
1813 Duration::from_millis(42)
1814 );
1815 }
1816
1817 #[test]
1818 fn percentile_empty_sample_is_zero() {
1819 assert_eq!(nearest_rank_percentile(&[], 50), Duration::ZERO);
1820 }
1821
1822 // ---------------------------------------------------------------
1823 // FrameStats
1824 // ---------------------------------------------------------------
1825
1826 /// The `encode_ms` argument feeds the `encode` span; the `acquire`/`submit`
1827 /// spans are left zero so existing total-time assertions are unchanged by the
1828 /// v3 field split — only the per-span attribution moved.
1829 fn passes(rebuild_ms: u64, layout_ms: u64, paint_ms: u64, encode_ms: u64) -> FramePasses {
1830 FramePasses {
1831 rebuild: Duration::from_millis(rebuild_ms),
1832 layout: Duration::from_millis(layout_ms),
1833 paint: Duration::from_millis(paint_ms),
1834 encode: Duration::from_millis(encode_ms),
1835 acquire: Duration::ZERO,
1836 submit: Duration::ZERO,
1837 skipped: false,
1838 gpu: None,
1839 }
1840 }
1841
1842 #[test]
1843 fn disabled_recorder_records_nothing_and_never_allocates() {
1844 let mut stats = FrameStats::new_enabled(false);
1845 for _ in 0..500 {
1846 stats.record(passes(20, 5, 5, 2));
1847 }
1848 assert_eq!(stats.total_frames(), 0);
1849 assert_eq!(
1850 stats.ring.capacity(),
1851 0,
1852 "disabled recorder must never reserve ring capacity"
1853 );
1854 let s = stats.summary();
1855 assert_eq!(s.frame_count, 0);
1856 assert_eq!(s.total_p50, Duration::ZERO);
1857 assert!(!stats.should_emit());
1858 }
1859
1860 #[test]
1861 fn over_budget_counters_are_exact() {
1862 let mut stats = FrameStats::new_enabled(true);
1863 // Under both budgets: 5ms total.
1864 stats.record(passes(2, 1, 1, 1));
1865 // Over 8.3ms but under 16.6ms: 10ms total.
1866 stats.record(passes(4, 2, 2, 2));
1867 // Over both: 20ms total.
1868 stats.record(passes(10, 5, 3, 2));
1869
1870 let s = stats.summary();
1871 assert_eq!(
1872 s.over_120hz_budget, 2,
1873 "10ms and 20ms frames exceed the 8.3ms budget"
1874 );
1875 assert_eq!(
1876 s.over_60hz_budget, 1,
1877 "only the 20ms frame exceeds the 16.6ms budget"
1878 );
1879 }
1880
1881 #[test]
1882 fn skipped_frames_counted_but_excluded_from_percentiles() {
1883 let mut stats = FrameStats::new_enabled(true);
1884 stats.record(passes(10, 2, 2, 2)); // 16ms, non-skipped
1885 stats.record(FramePasses {
1886 skipped: true,
1887 ..Default::default()
1888 }); // 0ms, skipped
1889 stats.record(passes(10, 2, 2, 2)); // 16ms, non-skipped
1890
1891 let s = stats.summary();
1892 assert_eq!(s.skipped_frames, 1);
1893 assert_eq!(stats.total_frames(), 3);
1894 // Percentile window only has the two 16ms non-skipped frames — a
1895 // skipped frame's 0ms would otherwise pull this down.
1896 assert_eq!(s.frame_count, 3, "ring buffer holds all recorded frames...");
1897 assert_eq!(
1898 s.total_p50,
1899 Duration::from_millis(16),
1900 "...but percentiles exclude the skipped one"
1901 );
1902 }
1903
1904 #[test]
1905 fn summary_attributes_encode_acquire_and_submit_spans_separately() {
1906 // The old combined present is now two spans (acquire +
1907 // submit) beside encode. A frame that spends 6ms encoding, 9ms on the
1908 // blocking acquire (vsync wait), and 3ms on the blit/submit must report
1909 // each p95 independently — not one conflated number.
1910 let mut stats = FrameStats::new_enabled(true);
1911 stats.record(FramePasses {
1912 rebuild: Duration::from_millis(2),
1913 layout: Duration::from_millis(1),
1914 paint: Duration::from_millis(1),
1915 encode: Duration::from_millis(6),
1916 acquire: Duration::from_millis(9),
1917 submit: Duration::from_millis(3),
1918 skipped: false,
1919 gpu: None,
1920 });
1921 let s = stats.summary();
1922 assert_eq!(
1923 s.encode_p95,
1924 Duration::from_millis(6),
1925 "encode span attributed"
1926 );
1927 assert_eq!(
1928 s.acquire_p95,
1929 Duration::from_millis(9),
1930 "acquire span attributed"
1931 );
1932 assert_eq!(
1933 s.submit_p95,
1934 Duration::from_millis(3),
1935 "submit span attributed"
1936 );
1937 // Total still sums every span (22ms here).
1938 assert_eq!(s.total_p95, Duration::from_millis(22));
1939 }
1940
1941 #[test]
1942 fn from_split_recombines_the_two_half_frames_without_changing_the_record() {
1943 // The UI thread measures rebuild/layout/paint, the render
1944 // thread measures encode/acquire/submit. `from_split` folds them into
1945 // the exact same FramePasses a single-thread frame would have built —
1946 // the render-thread split moves *where* spans are measured, not the
1947 // recorded shape or wire format.
1948 let ui = UiSpans {
1949 rebuild: Duration::from_millis(2),
1950 layout: Duration::from_millis(1),
1951 paint: Duration::from_millis(1),
1952 skipped: false,
1953 };
1954 let render = RenderSpans {
1955 encode: Duration::from_millis(6),
1956 acquire: Duration::from_millis(9),
1957 submit: Duration::from_millis(3),
1958 };
1959 let split = FramePasses::from_split(ui, render);
1960 let whole = FramePasses {
1961 rebuild: Duration::from_millis(2),
1962 layout: Duration::from_millis(1),
1963 paint: Duration::from_millis(1),
1964 encode: Duration::from_millis(6),
1965 acquire: Duration::from_millis(9),
1966 submit: Duration::from_millis(3),
1967 skipped: false,
1968 gpu: None,
1969 };
1970 assert_eq!(split, whole, "split reassembly must equal the whole frame");
1971 assert_eq!(split.total(), Duration::from_millis(22));
1972 }
1973
1974 /// The raw v3 wire line a split frame produces is byte-for-byte identical
1975 /// to the single-thread frame's — asserted separately because
1976 /// [`format_raw_frame_line`] is `perf-trace`-gated emission.
1977 #[cfg(feature = "perf-trace")]
1978 #[test]
1979 fn from_split_v3_wire_format_is_identical_to_single_thread() {
1980 let ui = UiSpans {
1981 rebuild: Duration::from_millis(2),
1982 layout: Duration::from_millis(1),
1983 paint: Duration::from_millis(1),
1984 skipped: false,
1985 };
1986 let render = RenderSpans {
1987 encode: Duration::from_millis(6),
1988 acquire: Duration::from_millis(9),
1989 submit: Duration::from_millis(3),
1990 };
1991 let split = FramePasses::from_split(ui, render);
1992 let whole = FramePasses {
1993 rebuild: Duration::from_millis(2),
1994 layout: Duration::from_millis(1),
1995 paint: Duration::from_millis(1),
1996 encode: Duration::from_millis(6),
1997 acquire: Duration::from_millis(9),
1998 submit: Duration::from_millis(3),
1999 skipped: false,
2000 gpu: None,
2001 };
2002 let mut split_line = String::new();
2003 let mut whole_line = String::new();
2004 format_raw_frame_line(&mut split_line, 1, &split);
2005 format_raw_frame_line(&mut whole_line, 1, &whole);
2006 assert_eq!(
2007 split_line, whole_line,
2008 "v3 wire format is unchanged by the split"
2009 );
2010 }
2011
2012 #[test]
2013 fn from_split_preserves_the_ui_side_skipped_verdict() {
2014 // The frame gate is UI-side, so a skipped frame's verdict rides in on
2015 // the UiSpans half and must survive the fold.
2016 let ui = UiSpans {
2017 skipped: true,
2018 ..Default::default()
2019 };
2020 let split = FramePasses::from_split(ui, RenderSpans::default());
2021 assert!(split.skipped, "the UI-side skip verdict must be preserved");
2022 assert_eq!(split.total(), Duration::ZERO);
2023 }
2024
2025 #[test]
2026 fn ring_buffer_evicts_oldest_beyond_capacity() {
2027 let mut stats = FrameStats::with_capacity_enabled(3, true);
2028 stats.record(passes(1, 0, 0, 0));
2029 stats.record(passes(2, 0, 0, 0));
2030 stats.record(passes(3, 0, 0, 0));
2031 stats.record(passes(4, 0, 0, 0)); // evicts the 1ms frame
2032
2033 let s = stats.summary();
2034 assert_eq!(s.frame_count, 3);
2035 // Sample is now {2, 3, 4}ms: p50 (rank ceil(150/100)=2) -> 3ms.
2036 assert_eq!(s.total_p50, Duration::from_millis(3));
2037 // Running total_frames is unaffected by eviction.
2038 assert_eq!(stats.total_frames(), 4);
2039 }
2040
2041 #[test]
2042 fn should_emit_rate_limits_on_accumulated_frame_time() {
2043 let mut stats = FrameStats::new_enabled(true);
2044 assert!(!stats.should_emit());
2045 // 100 frames * 16ms = 1600ms < the 2s interval.
2046 for _ in 0..100 {
2047 stats.record(passes(10, 3, 2, 1));
2048 }
2049 assert!(!stats.should_emit());
2050 // 25 more frames pushes accumulated time past 2000ms.
2051 for _ in 0..25 {
2052 stats.record(passes(10, 3, 2, 1));
2053 }
2054 assert!(stats.should_emit());
2055
2056 stats.emit_log();
2057 assert!(!stats.should_emit(), "emit_log resets the accumulator");
2058 }
2059
2060 #[test]
2061 fn a_reassembled_split_frame_carries_no_gpu_reading_until_one_is_attached() {
2062 let split = FramePasses::from_split(
2063 UiSpans {
2064 rebuild: Duration::from_millis(2),
2065 ..Default::default()
2066 },
2067 RenderSpans {
2068 encode: Duration::from_millis(6),
2069 acquire: Duration::from_millis(9),
2070 submit: Duration::from_millis(3),
2071 },
2072 );
2073 assert_eq!(split.gpu, None, "neither half measures GPU time");
2074
2075 let gpu = GpuPasses {
2076 prepass: Duration::from_micros(10),
2077 main: Duration::from_micros(20),
2078 composite: Duration::from_micros(30),
2079 blit: Duration::from_micros(40),
2080 };
2081 let timed = split.with_gpu(gpu);
2082 assert_eq!(timed.gpu, Some(gpu));
2083 assert_eq!(timed.total(), split.total(), "GPU time is not frame time");
2084 assert_eq!(gpu.total(), Duration::from_micros(100));
2085 // Every other field is untouched by the attachment.
2086 assert_eq!(FramePasses { gpu: None, ..timed }, split);
2087 }
2088
2089 #[test]
2090 fn an_all_zero_gpu_reading_is_still_a_reading() {
2091 // A frame that drew nothing off-screen genuinely spent no composite
2092 // time; the distinction between "measured zero" and "not measured" is
2093 // the `Option`, never a zero value.
2094 let timed = FramePasses::default().with_gpu(GpuPasses::default());
2095 assert_eq!(timed.gpu, Some(GpuPasses::default()));
2096 assert_eq!(timed.gpu.map(|gpu| gpu.total()), Some(Duration::ZERO));
2097 assert_eq!(FramePasses::default().gpu, None);
2098 }
2099
2100 // ---------------------------------------------------------------
2101 // Raw per-frame export + scenario markers
2102 // ---------------------------------------------------------------
2103
2104 /// A raw per-frame line's parsed fields — this module's own round-trip
2105 /// check that [`format_raw_frame_line`]'s shape is exactly what a
2106 /// `key=value`-splitting harness would expect; not part of the crate's
2107 /// public API (the real harness is a separate process parsing
2108 /// `stdout`/`logcat` text, not a Rust consumer of this module). Gated with
2109 /// the emission it exercises.
2110 #[cfg(feature = "perf-trace")]
2111 struct ParsedRawFrameLine {
2112 n: u64,
2113 total_us: u128,
2114 rebuild_us: u128,
2115 layout_us: u128,
2116 paint_us: u128,
2117 encode_us: u128,
2118 acquire_us: u128,
2119 submit_us: u128,
2120 skipped: bool,
2121 /// v4's `gpu_q` marker, plus every `gpu_*_us` field it gates, in the
2122 /// order the line wrote them — absent as a group whenever `gpu_q=0`.
2123 gpu_q: bool,
2124 gpu: Vec<(String, u128)>,
2125 }
2126
2127 #[cfg(feature = "perf-trace")]
2128 fn parse_raw_frame_line(line: &str) -> Option<ParsedRawFrameLine> {
2129 let rest = line.strip_prefix(RAW_FRAME_PREFIX)?.trim_start();
2130 let mut n = None;
2131 let mut total_us = None;
2132 let mut rebuild_us = None;
2133 let mut layout_us = None;
2134 let mut paint_us = None;
2135 let mut encode_us = None;
2136 let mut acquire_us = None;
2137 let mut submit_us = None;
2138 let mut skipped = None;
2139 let mut gpu_q = None;
2140 let mut gpu = Vec::new();
2141 for field in rest.split_whitespace() {
2142 let (key, value) = field.split_once('=')?;
2143 match key {
2144 "n" => n = value.parse().ok(),
2145 "total_us" => total_us = value.parse().ok(),
2146 "rebuild_us" => rebuild_us = value.parse().ok(),
2147 "layout_us" => layout_us = value.parse().ok(),
2148 "paint_us" => paint_us = value.parse().ok(),
2149 "encode_us" => encode_us = value.parse().ok(),
2150 "acquire_us" => acquire_us = value.parse().ok(),
2151 "submit_us" => submit_us = value.parse().ok(),
2152 "skipped" => skipped = value.parse::<u8>().ok().map(|v| v != 0),
2153 "gpu_q" => gpu_q = value.parse::<u8>().ok().map(|v| v != 0),
2154 other if other.starts_with("gpu_") => {
2155 gpu.push((other.to_string(), value.parse().ok()?));
2156 }
2157 _ => {}
2158 }
2159 }
2160 Some(ParsedRawFrameLine {
2161 n: n?,
2162 total_us: total_us?,
2163 rebuild_us: rebuild_us?,
2164 layout_us: layout_us?,
2165 paint_us: paint_us?,
2166 encode_us: encode_us?,
2167 acquire_us: acquire_us?,
2168 submit_us: submit_us?,
2169 skipped: skipped?,
2170 gpu_q: gpu_q?,
2171 gpu,
2172 })
2173 }
2174
2175 #[cfg(feature = "perf-trace")]
2176 #[test]
2177 fn raw_frame_line_format_round_trips() {
2178 let mut buf = String::new();
2179 let p = FramePasses {
2180 rebuild: Duration::from_micros(1234),
2181 layout: Duration::from_micros(200),
2182 paint: Duration::from_micros(300),
2183 encode: Duration::from_micros(50),
2184 acquire: Duration::from_micros(80),
2185 submit: Duration::from_micros(40),
2186 skipped: false,
2187 gpu: None,
2188 };
2189 format_raw_frame_line(&mut buf, 42, &p);
2190 assert!(buf.starts_with(RAW_FRAME_PREFIX));
2191
2192 let parsed = parse_raw_frame_line(&buf).expect("line must parse");
2193 assert_eq!(parsed.n, 42);
2194 assert_eq!(parsed.total_us, p.total().as_micros());
2195 assert_eq!(parsed.rebuild_us, 1234);
2196 assert_eq!(parsed.layout_us, 200);
2197 assert_eq!(parsed.paint_us, 300);
2198 assert_eq!(parsed.encode_us, 50);
2199 assert_eq!(parsed.acquire_us, 80);
2200 assert_eq!(parsed.submit_us, 40);
2201 assert!(!parsed.skipped);
2202 assert!(!parsed.gpu_q, "a frame with no GPU reading reports gpu_q=0");
2203 }
2204
2205 #[cfg(feature = "perf-trace")]
2206 #[test]
2207 fn a_frame_without_a_gpu_reading_writes_gpu_q_zero_and_no_gpu_columns() {
2208 // The no-TIMESTAMP_QUERY shape: the marker says there is no reading,
2209 // and the five `gpu_*_us` fields are absent rather than written as
2210 // zeros — a column of zeros in a raw series reads like a measured
2211 // result, which is exactly what this must not produce.
2212 let mut buf = String::new();
2213 format_raw_frame_line(&mut buf, 3, &passes(10, 2, 2, 2));
2214
2215 assert!(buf.contains(" gpu_q=0"));
2216 assert!(
2217 !buf.contains("gpu_total_us"),
2218 "no gpu_* column may be emitted without a reading: {buf}"
2219 );
2220 let parsed = parse_raw_frame_line(&buf).expect("line must parse");
2221 assert!(!parsed.gpu_q);
2222 assert!(parsed.gpu.is_empty());
2223 }
2224
2225 #[cfg(feature = "perf-trace")]
2226 #[test]
2227 fn an_unmeasured_main_span_reports_gpu_q_zero_not_a_zero_column() {
2228 // A clear-only opening pass resolves no timestamp pair, so `main`
2229 // reads zero while other passes of the same frame did measure.
2230 let gpu = GpuPasses {
2231 prepass: Duration::from_micros(120),
2232 main: Duration::ZERO,
2233 composite: Duration::ZERO,
2234 blit: Duration::from_micros(75),
2235 };
2236 assert!(!gpu.main_is_measured());
2237 let mut buf = String::new();
2238 format_raw_frame_line(&mut buf, 5, &passes(4, 1, 1, 2).with_gpu(gpu));
2239
2240 assert!(buf.contains(" gpu_q=0"), "{buf}");
2241 assert!(!buf.contains("gpu_main_us"), "{buf}");
2242 assert!(!buf.contains("gpu_total_us"), "{buf}");
2243 let parsed = parse_raw_frame_line(&buf).expect("line must parse");
2244 assert!(!parsed.gpu_q);
2245 assert!(parsed.gpu.is_empty());
2246 }
2247
2248 #[cfg(feature = "perf-trace")]
2249 #[test]
2250 fn a_nonzero_main_span_with_zero_siblings_stays_a_measurement() {
2251 let gpu = GpuPasses {
2252 main: Duration::from_nanos(400),
2253 ..GpuPasses::default()
2254 };
2255 assert!(gpu.main_is_measured());
2256 let mut buf = String::new();
2257 format_raw_frame_line(&mut buf, 6, &passes(4, 1, 1, 2).with_gpu(gpu));
2258 assert!(buf.contains(" gpu_q=1"), "{buf}");
2259 assert!(buf.contains(" gpu_main_us=0"), "{buf}");
2260 }
2261
2262 #[cfg(feature = "perf-trace")]
2263 #[test]
2264 fn a_gpu_timed_frame_appends_every_span_in_the_declared_order() {
2265 let gpu = GpuPasses {
2266 prepass: Duration::from_micros(120),
2267 main: Duration::from_micros(2400),
2268 composite: Duration::from_micros(650),
2269 blit: Duration::from_micros(75),
2270 };
2271 let mut buf = String::new();
2272 format_raw_frame_line(&mut buf, 9, &passes(4, 1, 1, 2).with_gpu(gpu));
2273
2274 let parsed = parse_raw_frame_line(&buf).expect("line must parse");
2275 assert!(parsed.gpu_q);
2276 assert_eq!(
2277 parsed.gpu,
2278 vec![
2279 ("gpu_total_us".to_string(), 3245),
2280 ("gpu_prepass_us".to_string(), 120),
2281 ("gpu_main_us".to_string(), 2400),
2282 ("gpu_composite_us".to_string(), 650),
2283 ("gpu_blit_us".to_string(), 75),
2284 ],
2285 "field order is wire contract: {buf}"
2286 );
2287 assert_eq!(gpu.total(), Duration::from_micros(3245));
2288 }
2289
2290 #[cfg(feature = "perf-trace")]
2291 #[test]
2292 fn the_v3_prefix_of_a_v4_line_is_byte_identical() {
2293 // v4 is additive: appending the GPU fields must not perturb one byte
2294 // of what a v3 parser reads, so every series already captured stays
2295 // comparable against one captured after this change.
2296 let base = passes(4, 1, 1, 2);
2297 let mut v3_line = String::new();
2298 let mut v4_line = String::new();
2299 format_raw_frame_line(&mut v3_line, 11, &base);
2300 format_raw_frame_line(
2301 &mut v4_line,
2302 11,
2303 &base.with_gpu(GpuPasses {
2304 main: Duration::from_micros(1),
2305 ..GpuPasses::default()
2306 }),
2307 );
2308
2309 let (v3_head, v3_marker) = v3_line
2310 .rsplit_once(" gpu_q=")
2311 .expect("every v4 line carries the marker");
2312 assert_eq!(v3_marker, "0");
2313 assert!(
2314 v4_line.starts_with(v3_head),
2315 "the v3 field set must be byte-identical:\n{v3_line}\n{v4_line}"
2316 );
2317 }
2318
2319 #[cfg(feature = "perf-trace")]
2320 #[test]
2321 fn a_gpu_timed_frames_total_excludes_the_gpu_spans() {
2322 // GPU passes run concurrently with the CPU spans, so folding them into
2323 // `total_us` would double-count the frame and break every percentile
2324 // computed off it.
2325 let base = passes(4, 1, 1, 2);
2326 let timed = base.with_gpu(GpuPasses {
2327 main: Duration::from_millis(50),
2328 ..GpuPasses::default()
2329 });
2330 assert_eq!(timed.total(), base.total());
2331
2332 let mut buf = String::new();
2333 format_raw_frame_line(&mut buf, 1, &timed);
2334 let parsed = parse_raw_frame_line(&buf).expect("line must parse");
2335 assert_eq!(parsed.total_us, base.total().as_micros());
2336 }
2337
2338 #[cfg(feature = "perf-trace")]
2339 #[test]
2340 fn raw_frame_line_represents_skipped_flag() {
2341 let mut buf = String::new();
2342 let p = FramePasses {
2343 skipped: true,
2344 ..Default::default()
2345 };
2346 format_raw_frame_line(&mut buf, 7, &p);
2347
2348 let parsed = parse_raw_frame_line(&buf).expect("line must parse");
2349 assert!(parsed.skipped, "skipped frame must still be represented");
2350 assert_eq!(parsed.total_us, 0);
2351 assert_eq!(parsed.n, 7);
2352 }
2353
2354 #[cfg(feature = "perf-trace")]
2355 #[test]
2356 fn raw_enabled_record_formats_one_line_per_frame() {
2357 let mut stats = FrameStats::with_capacity_enabled_and_raw(4, true, true);
2358 stats.record(passes(10, 2, 2, 2));
2359 let parsed = parse_raw_frame_line(&stats.raw_buf).expect("line must parse");
2360 assert_eq!(parsed.n, 1);
2361
2362 stats.record(passes(5, 1, 1, 1));
2363 let parsed = parse_raw_frame_line(&stats.raw_buf).expect("line must parse");
2364 assert_eq!(parsed.n, 2, "frame index advances per recorded frame");
2365 }
2366
2367 #[test]
2368 fn raw_disabled_record_never_touches_raw_line_buffer() {
2369 let mut stats = FrameStats::with_capacity_enabled_and_raw(4, true, false);
2370 for _ in 0..5 {
2371 stats.record(passes(1, 1, 1, 1));
2372 }
2373 assert!(
2374 stats.raw_buf.is_empty(),
2375 "raw-export off must never format into the raw line buffer"
2376 );
2377 assert_eq!(stats.total_frames(), 5, "non-raw recording still happens");
2378 }
2379
2380 #[test]
2381 fn raw_requires_enabled_too() {
2382 // enabled=false + is_raw=true: the whole recorder (including raw)
2383 // stays off — enabled() gates raw_enabled(), not the other way
2384 // around.
2385 let mut stats = FrameStats::with_capacity_enabled_and_raw(4, false, true);
2386 stats.record(passes(10, 2, 2, 2));
2387 assert_eq!(stats.total_frames(), 0, "disabled recorder still no-ops");
2388 assert!(stats.raw_buf.is_empty());
2389 }
2390
2391 #[cfg(feature = "perf-trace")]
2392 #[test]
2393 fn scenario_marker_format_start_and_end() {
2394 assert_eq!(
2395 format_scenario_marker(MarkerEdge::Start, "cold_start", 5),
2396 "bench-scenario-start n=5 cold_start"
2397 );
2398 assert_eq!(
2399 format_scenario_marker(MarkerEdge::End, "cold_start", 6),
2400 "bench-scenario-end n=6 cold_start"
2401 );
2402 }
2403
2404 #[cfg(feature = "perf-trace")]
2405 #[test]
2406 fn markers_raised_while_perf_is_disabled_accumulate_nothing() {
2407 // Perf off (the guard leaves the force switch clear, and no test-run
2408 // process sets FRUST_TRACE): raising markers must be safe AND must
2409 // queue nothing at all — a queue nobody ever drains is the one way
2410 // this route could leak in a shipped build.
2411 let _guard = marker_test_guard(false);
2412
2413 mark_scenario_start("smoke");
2414 mark_scenario_end("smoke");
2415
2416 assert!(
2417 take_pending_markers().is_empty(),
2418 "a disabled marker must never reach the queue"
2419 );
2420 }
2421
2422 #[cfg(feature = "perf-trace")]
2423 #[test]
2424 fn inline_executor_drains_this_threads_pending_queue_at_record() {
2425 // The inline (no render thread) path: one thread raises and records,
2426 // so `record` drains that thread's own queue for the frame it is
2427 // recording right now. No route flag decides this — the queue it
2428 // drains is simply the queue the markers were raised into.
2429 let _guard = marker_test_guard(true);
2430 let mut stats = FrameStats::with_capacity_enabled_and_raw(4, true, true);
2431
2432 mark_scenario_start("s3-create1k");
2433 stats.record(passes(10, 2, 2, 2));
2434 assert_eq!(
2435 stats.marker_log(),
2436 ["bench-scenario-start n=1 s3-create1k"],
2437 "the marker rides the frame being recorded when it was raised"
2438 );
2439
2440 // The next build closes the window: S3 raises `end` one build later,
2441 // so it lands on frame 2 and the half-open window is exactly frame 1.
2442 mark_scenario_end("s3-create1k");
2443 stats.record(passes(10, 2, 2, 2));
2444 assert_eq!(
2445 stats.marker_log(),
2446 [
2447 "bench-scenario-start n=1 s3-create1k",
2448 "bench-scenario-end n=2 s3-create1k",
2449 ]
2450 );
2451
2452 // A frame carrying no marker adds no line.
2453 stats.record(passes(10, 2, 2, 2));
2454 assert_eq!(stats.marker_log().len(), 2);
2455 assert_eq!(stats.total_frames(), 3);
2456 }
2457
2458 #[cfg(feature = "perf-trace")]
2459 #[test]
2460 fn markers_are_emitted_with_raw_export_off() {
2461 // The raw-export dial gates the per-frame line, not the markers: a
2462 // window must exist in every perf-enabled capture, not only in the
2463 // ones a harness asked for raw frames in.
2464 let _guard = marker_test_guard(true);
2465 let mut stats = FrameStats::with_capacity_enabled_and_raw(4, true, false);
2466
2467 mark_scenario_start("s8-write");
2468 stats.record(passes(10, 2, 2, 2));
2469
2470 assert_eq!(stats.marker_log(), ["bench-scenario-start n=1 s8-write"]);
2471 assert!(
2472 stats.raw_buf.is_empty(),
2473 "raw export stays off — only the marker line was emitted"
2474 );
2475 }
2476
2477 #[cfg(feature = "perf-trace")]
2478 #[test]
2479 fn a_disabled_recorder_emits_no_marker_line() {
2480 // `record`'s own disabled early-return covers the markers too: a
2481 // recorder that counts no frames can stamp no frame number.
2482 let _guard = marker_test_guard(true);
2483 let mut stats = FrameStats::with_capacity_enabled_and_raw(4, false, true);
2484
2485 mark_scenario_start("s3-update");
2486 stats.record(passes(10, 2, 2, 2));
2487
2488 assert!(stats.marker_log().is_empty());
2489 assert_eq!(stats.total_frames(), 0);
2490 }
2491
2492 #[cfg(feature = "perf-trace")]
2493 #[test]
2494 fn staged_markers_ride_the_next_recorded_frame_in_order() {
2495 // The render thread's leg in isolation: whatever `stage_markers` was
2496 // handed comes out on the next frame this thread records, in the
2497 // order it was raised, and only once.
2498 let _guard = marker_test_guard(true);
2499 let mut stats = FrameStats::with_capacity_enabled_and_raw(4, true, true);
2500
2501 mark_scenario_start("s3-clear");
2502 mark_scenario_end("s3-clear");
2503 stage_markers(take_pending_markers());
2504
2505 stats.record(passes(10, 2, 2, 2));
2506 assert_eq!(
2507 stats.marker_log(),
2508 [
2509 "bench-scenario-start n=1 s3-clear",
2510 "bench-scenario-end n=1 s3-clear",
2511 ],
2512 "both edges collapsing onto one frame is the empty half-open \
2513 window a dropped op frame honestly produces"
2514 );
2515
2516 stats.record(passes(10, 2, 2, 2));
2517 assert_eq!(stats.marker_log().len(), 2, "staged markers emit once");
2518 }
2519
2520 #[cfg(feature = "perf-trace")]
2521 #[test]
2522 fn stage_markers_of_an_empty_batch_is_a_no_op() {
2523 let _guard = marker_test_guard(true);
2524 let mut stats = FrameStats::with_capacity_enabled_and_raw(4, true, true);
2525
2526 stage_markers(take_pending_markers());
2527 stats.record(passes(10, 2, 2, 2));
2528
2529 assert!(stats.marker_log().is_empty());
2530 }
2531
2532 #[cfg(feature = "perf-trace")]
2533 #[test]
2534 fn an_overflowing_queue_drops_the_oldest_markers_and_reports_the_count() {
2535 // The bound (`MARKER_QUEUE_CAP`): a queue that is raised into far
2536 // faster than frames are recorded must stop growing, keep the
2537 // NEWEST edges (a live window needs those), and say how many it
2538 // discarded — on a line of its own, never inside a
2539 // `bench-scenario-*` line, whose shape is a parsed wire format.
2540 let _guard = marker_test_guard(true);
2541 let mut stats = FrameStats::with_capacity_enabled_and_raw(4, true, true);
2542
2543 let raised = MARKER_QUEUE_CAP + 3;
2544 for i in 0..raised {
2545 mark_scenario_start(&format!("s3-op{i}"));
2546 }
2547 stats.record(passes(10, 2, 2, 2));
2548
2549 let log = stats.marker_log();
2550 assert_eq!(
2551 log.len(),
2552 MARKER_QUEUE_CAP + 1,
2553 "a capped queue's worth of markers, plus one overflow notice"
2554 );
2555 assert_eq!(
2556 log[0], "bench-scenario-start n=1 s3-op3",
2557 "the three OLDEST were dropped, and the survivors keep the \
2558 unannotated marker shape"
2559 );
2560 assert_eq!(
2561 log[MARKER_QUEUE_CAP - 1],
2562 format!("bench-scenario-start n=1 s3-op{}", raised - 1),
2563 "the newest raised marker survived"
2564 );
2565 assert_eq!(
2566 log[MARKER_QUEUE_CAP],
2567 "frust-perf marker-overflow n=1 dropped=3"
2568 );
2569
2570 // The count travels with the batch that carried it, so a later frame
2571 // does not re-report it.
2572 mark_scenario_end("s3-done");
2573 stats.record(passes(10, 2, 2, 2));
2574 assert_eq!(
2575 stats.marker_log()[MARKER_QUEUE_CAP + 1..],
2576 ["bench-scenario-end n=2 s3-done"]
2577 );
2578 }
2579
2580 #[test]
2581 fn disabled_should_emit_and_emit_log_are_no_ops() {
2582 let mut stats = FrameStats::new_enabled(false);
2583 assert!(!stats.should_emit());
2584 stats.emit_log(); // must not panic; nothing to assert on directly
2585 assert_eq!(stats.total_frames(), 0);
2586 }
2587
2588 // ---------------------------------------------------------------
2589 // StartupSpans
2590 // ---------------------------------------------------------------
2591
2592 /// A deterministic fake clock advancing by a fixed step each call.
2593 struct FakeClock {
2594 elapsed: Duration,
2595 step: Duration,
2596 }
2597
2598 impl Clock for FakeClock {
2599 fn now(&mut self) -> Duration {
2600 let now = self.elapsed;
2601 self.elapsed += self.step;
2602 now
2603 }
2604 }
2605
2606 #[test]
2607 fn spans_recorded_in_order_with_monotonic_nonnegative_deltas() {
2608 let clock = FakeClock {
2609 elapsed: Duration::ZERO,
2610 step: Duration::from_millis(10),
2611 };
2612 let mut spans = StartupSpans::begin_with_enabled(clock, true);
2613 spans.record(SPAN_INIT_ENTRY);
2614 spans.record(SPAN_ADAPTER_READY);
2615 spans.record(SPAN_DEVICE_READY);
2616 spans.record(SPAN_RENDERER_READY);
2617
2618 let recorded = spans.spans();
2619 assert_eq!(recorded.len(), 4);
2620 assert_eq!(recorded[0].0, SPAN_INIT_ENTRY);
2621 assert_eq!(recorded[3].0, SPAN_RENDERER_READY);
2622
2623 let mut last = Duration::ZERO;
2624 for (_, delta) in recorded {
2625 assert!(*delta >= last, "deltas must be non-decreasing");
2626 assert!(*delta >= Duration::ZERO);
2627 last = *delta;
2628 }
2629 // begin() consumed the first fake-clock reading (0ms), so the first
2630 // recorded span is 10ms, the second 20ms, etc.
2631 assert_eq!(recorded[0].1, Duration::from_millis(10));
2632 assert_eq!(recorded[1].1, Duration::from_millis(20));
2633 assert_eq!(recorded[2].1, Duration::from_millis(30));
2634 assert_eq!(recorded[3].1, Duration::from_millis(40));
2635 }
2636
2637 #[test]
2638 fn disabled_spans_record_nothing() {
2639 let clock = FakeClock {
2640 elapsed: Duration::ZERO,
2641 step: Duration::from_millis(10),
2642 };
2643 let mut spans = StartupSpans::begin_with_enabled(clock, false);
2644 spans.record(SPAN_INIT_ENTRY);
2645 assert!(spans.spans().is_empty());
2646 spans.emit_log(); // no-op, must not panic
2647 }
2648
2649 #[test]
2650 fn closure_clock_satisfies_clock_trait() {
2651 let mut n = 0u64;
2652 let clock = move || {
2653 n += 10;
2654 Duration::from_millis(n)
2655 };
2656 let mut spans = StartupSpans::begin_with_enabled(clock, true);
2657 spans.record(SPAN_FIRST_REBUILD_DONE);
2658 spans.record(SPAN_FIRST_FRAME_PRESENTED);
2659 assert_eq!(spans.spans().len(), 2);
2660 assert!(spans.spans()[1].1 >= spans.spans()[0].1);
2661 }
2662}