Skip to main content

incular_runtime/
profiling.rs

1//! Low-overhead performance profiling model.
2//!
3//! Cheap counters always exist; timing history is opt-in via
4//! [`ProfilerMode`]. The hot path records into fixed-size `Copy` structs — no
5//! dynamic allocation, no string formatting, no locks on the UI thread.
6
7use serde::Serialize;
8use std::cell::{Cell, RefCell};
9use std::time::{Duration, Instant};
10
11/// How much profiling state the framework retains per frame.
12///
13/// [`ProfilerMode::Normal`] keeps only the latest frame record plus cumulative
14/// counters, so ordinary applications pay one struct copy per frame.
15/// History modes additionally retain a bounded ring of recent frames for
16/// percentile reporting.
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
18pub enum ProfilerMode {
19    /// Counters and latest frame only. Default production mode.
20    #[default]
21    Normal,
22    /// Retains bounded frame history for diagnostics tooling.
23    Diagnostic,
24    /// History plus fine-grained spans; intended for local investigation.
25    Profiling,
26}
27
28/// Monotonic per-phase CPU durations for exactly one frame, in microseconds.
29///
30/// Phases do not nest: each duration covers only work attributed to that
31/// phase, so the sum equals the measured CPU frame cost.
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
33pub struct FrameTimings {
34    /// Input dispatch before the frame began.
35    pub event_processing: u32,
36    /// Tokio/UI message drain and async completions.
37    pub runtime_messages: u32,
38    /// BUILD phase: widget updates and reactive rebuilds.
39    pub build: u32,
40    /// LAYOUT phase.
41    pub layout: u32,
42    /// COMPOSITE phase (retained layer transforms/opacity/effects).
43    pub composite: u32,
44    /// SEMANTICS phase.
45    pub semantics: u32,
46    /// PAINT phase including display-list flattening.
47    pub paint: u32,
48    /// Sum of all phases above.
49    pub cpu_total: u32,
50}
51impl FrameTimings {
52    #[must_use]
53    pub fn total(&self) -> Duration {
54        Duration::from_micros(u64::from(self.cpu_total))
55    }
56}
57
58/// Structural work executed during one frame (deltas, not totals).
59#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
60pub struct FrameWork {
61    pub updated_elements: u64,
62    pub rebuilt_elements: u64,
63    pub laid_out_render_objects: u64,
64    pub repainted_render_objects: u64,
65    pub composited_layers: u64,
66    pub active_animations: u64,
67    pub display_list_commands: usize,
68    pub requested_another_frame: bool,
69}
70
71/// One observed frame: identity, timings, work counters, budget verdict.
72#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
73pub struct FrameRecord {
74    pub frame: u64,
75    pub timings: FrameTimings,
76    pub work: FrameWork,
77    /// True when this frame exceeded the configured refresh budget.
78    pub over_budget: bool,
79}
80
81/// Bounded statistics over retained frame history.
82#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)]
83pub struct FrameStatistics {
84    pub samples: u64,
85    pub latest_us: f64,
86    pub mean_us: f64,
87    pub min_us: f64,
88    pub max_us: f64,
89    pub p50_us: f64,
90    pub p95_us: f64,
91    pub p99_us: f64,
92}
93
94/// Fixed-capacity ring of recent frames. Capacity is clamped to
95/// `[300, 1000]`; insertion never allocates beyond the initial buffer.
96#[derive(Debug)]
97pub struct FrameHistory {
98    records: std::collections::VecDeque<FrameRecord>,
99    capacity: usize,
100}
101impl FrameHistory {
102    #[must_use]
103    pub fn new(capacity: usize) -> Self {
104        Self {
105            records: std::collections::VecDeque::with_capacity(capacity.clamp(300, 1000)),
106            capacity: capacity.clamp(300, 1000),
107        }
108    }
109    pub fn push(&mut self, record: FrameRecord) {
110        if self.records.len() == self.capacity {
111            self.records.pop_front();
112        }
113        self.records.push_back(record);
114    }
115    /// Maximum retained samples.
116    #[must_use]
117    pub const fn len(&self) -> usize {
118        self.capacity
119    }
120    #[must_use]
121    pub const fn is_empty(&self) -> bool {
122        false
123    }
124    #[must_use]
125    pub fn recorded(&self) -> usize {
126        self.records.len()
127    }
128    #[must_use]
129    pub fn latest(&self) -> Option<FrameRecord> {
130        self.records.back().copied()
131    }
132    pub fn iter(&self) -> impl Iterator<Item = FrameRecord> + '_ {
133        self.records.iter().copied()
134    }
135    /// Percentile summary of CPU frame time. Sorting happens only when called;
136    /// the hot path never invokes this.
137    #[must_use]
138    pub fn statistics(&self) -> FrameStatistics {
139        let mut samples: Vec<u32> = self.records.iter().map(|f| f.timings.cpu_total).collect();
140        let count = samples.len() as u64;
141        if count == 0 {
142            return FrameStatistics::default();
143        }
144        samples.sort_unstable();
145        let pick = |percentile: f64| -> f64 {
146            let index = ((count as f64 - 1.) * percentile).round() as usize;
147            f64::from(samples[index.min(samples.len() - 1)])
148        };
149        let sum: u64 = samples.iter().map(|s| u64::from(*s)).sum();
150        FrameStatistics {
151            samples: count,
152            latest_us: f64::from(*samples.last().expect("non-empty")),
153            mean_us: sum as f64 / count as f64,
154            min_us: f64::from(samples[0]),
155            max_us: f64::from(samples[samples.len() - 1]),
156            p50_us: pick(0.50),
157            p95_us: pick(0.95),
158            p99_us: pick(0.99),
159        }
160    }
161}
162
163/// Aggregated counter snapshot for scheduler/reactivity behavior.
164#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
165pub struct SchedulerCounters {
166    /// Number of signal reads.
167    pub signal_reads: u64,
168    /// Number of state-changing signal writes.
169    pub signal_writes: u64,
170    /// Number of unique dependent queue entries created after deduplication.
171    pub dependents_enqueued: u64,
172    pub runtime_wakes: u64,
173    pub redraw_requests: u64,
174    pub frames_started: u64,
175    pub frames_presented: u64,
176    pub frames_skipped: u64,
177}
178
179#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)]
180pub struct BudgetStatistics {
181    /// Refresh rate used to derive the per-frame budget, if known.
182    pub refresh_rate_hz: Option<f32>,
183    /// Derived budget in microseconds; `None` without a known rate.
184    pub budget_us: Option<f64>,
185    pub frames_over_budget: u64,
186    pub longest_frame_us: f64,
187}
188
189/// Per-application profiling coordinator. Lives off the hot path except for
190/// one `record` call per frame.
191#[derive(Debug)]
192pub struct PerformanceProfiler {
193    mode: ProfilerMode,
194    history: FrameHistory,
195    budget: BudgetStatistics,
196    next_frame: u64,
197    /// Bounded presentation-timestamp ring for true FPS (wall clock).
198    present_times: std::collections::VecDeque<Instant>,
199}
200impl PerformanceProfiler {
201    const FPS_WINDOW: usize = 120;
202
203    /// Frames per second over the recent presentation window, using actual
204    /// present timestamps rather than CPU frame cost.
205    #[must_use]
206    pub fn frames_per_second(&self) -> Option<f32> {
207        let first = *self.present_times.front()?;
208        let last = *self.present_times.back()?;
209        let elapsed = last.duration_since(first).as_secs_f32();
210        (elapsed > 0.).then(|| (self.present_times.len().saturating_sub(1)) as f32 / elapsed)
211    }
212    pub(crate) fn note_present(&mut self, presented: bool) {
213        if !presented {
214            return;
215        }
216        if self.present_times.len() == Self::FPS_WINDOW {
217            self.present_times.pop_front();
218        }
219        self.present_times.push_back(Instant::now());
220    }
221}
222impl PerformanceProfiler {
223    #[must_use]
224    pub fn new(mode: ProfilerMode) -> Self {
225        Self {
226            mode,
227            history: FrameHistory::new(512),
228            budget: BudgetStatistics::default(),
229            next_frame: 1,
230            present_times: std::collections::VecDeque::with_capacity(Self::FPS_WINDOW),
231        }
232    }
233    #[must_use]
234    pub const fn mode(&self) -> ProfilerMode {
235        self.mode
236    }
237    pub fn set_mode(&mut self, mode: ProfilerMode) {
238        self.mode = mode;
239    }
240    #[must_use]
241    pub const fn history(&self) -> &FrameHistory {
242        &self.history
243    }
244    #[must_use]
245    pub const fn budget(&self) -> &BudgetStatistics {
246        &self.budget
247    }
248    /// Derives the frame budget from an actual refresh rate. `None` clears it.
249    pub fn set_refresh_rate_hz(&mut self, hz: Option<f32>) {
250        self.budget.refresh_rate_hz = hz.filter(|rate| *rate > 0.);
251        self.budget.budget_us = self
252            .budget
253            .refresh_rate_hz
254            .map(|rate| 1_000_000. / f64::from(rate));
255    }
256    #[must_use]
257    pub fn next_frame_id(&mut self) -> u64 {
258        let id = self.next_frame;
259        self.next_frame += 1;
260        id
261    }
262    /// Records one finished frame. In [`ProfilerMode::Normal`] only budget and
263    /// longest-frame accounting runs; history retention requires a higher mode.
264    pub fn record(&mut self, mut record: FrameRecord) {
265        if let Some(budget_us) = self.budget.budget_us {
266            record.over_budget = f64::from(record.timings.cpu_total) > budget_us;
267            if record.over_budget {
268                self.budget.frames_over_budget += 1;
269            }
270        }
271        let total = f64::from(record.timings.cpu_total);
272        if total > self.budget.longest_frame_us {
273            self.budget.longest_frame_us = total;
274        }
275        if self.mode != ProfilerMode::Normal {
276            self.history.push(record);
277        }
278    }
279}
280
281/// Measures a phase boundary into microseconds on a fixed-width field.
282#[derive(Debug)]
283pub struct PhaseSpan {
284    started: Instant,
285}
286impl PhaseSpan {
287    #[must_use]
288    pub fn start() -> Self {
289        Self {
290            started: Instant::now(),
291        }
292    }
293    #[must_use]
294    pub fn elapsed_us(&self) -> u32 {
295        self.started
296            .elapsed()
297            .as_micros()
298            .try_into()
299            .unwrap_or(u32::MAX)
300    }
301}
302
303/// Renderer-reported metrics for exactly one presented or skipped frame.
304/// Platform adapters convert backend-specific statistics into this compact
305/// snapshot so the runtime never depends on a particular GPU backend.
306#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
307pub struct RenderFrameMetrics {
308    pub frame: u64,
309    pub draw_calls: u32,
310    pub instances: u32,
311    pub render_passes: u32,
312    /// Path triangles submitted (three vertices each).
313    pub path_triangles: u32,
314    /// Instance-buffer bytes written this frame.
315    pub upload_bytes: u64,
316    /// Texture pixel bytes written this frame.
317    pub texture_upload_bytes: u64,
318    /// Pipeline creations this frame. The steady-state invariant is zero;
319    /// nonzero values occur only at initialization or genuine format change.
320    pub pipelines_created: u32,
321    /// Queue submissions this frame (main plus offscreen effect work).
322    pub queue_submissions: u32,
323    pub prepare_us: u32,
324    pub encode_us: u32,
325    pub submit_us: u32,
326}
327
328/// Shared and per-window GPU residency snapshot reported by the adapter
329/// without rendering. Plain integers only — like [`RenderFrameMetrics`],
330/// platform adapters convert backend-specific statistics into this compact
331/// snapshot so the runtime never depends on a particular GPU backend.
332/// Shared counts describe the device-owned caches; `local_*` counts
333/// describe the queried window's retained bindings. Cumulative rasterized
334/// and eviction counters sit alongside current residency so tests and
335/// hosts can separate what is retained now from what has happened.
336#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
337pub struct GpuResourceSummary {
338    /// Backend allocator's live resource bytes, if reporting is supported.
339    pub allocator_live_bytes: Option<u64>,
340    /// Backend allocator's reserved block bytes; excludes driver/swapchain data.
341    pub allocator_reserved_bytes: Option<u64>,
342    /// Full-window stencil attachments allocated for this window. Only frames
343    /// that draw non-rectangular clips allocate one.
344    pub window_stencil_creations: u64,
345    /// Full-window stencil attachments released by a resize.
346    pub window_stencil_recreations: u64,
347    /// Shared image textures currently retained.
348    pub shared_image_entries: u64,
349    /// Shared image entries dropped by budget eviction (cumulative).
350    pub shared_image_evictions: u64,
351    /// Shared gradient textures currently retained.
352    pub shared_gradient_entries: u64,
353    /// Shared gradient entries dropped by budget eviction (cumulative).
354    pub shared_gradient_evictions: u64,
355    /// Live glyph atlas pages.
356    pub glyph_live_pages: u64,
357    /// Glyph atlas pages retired by eviction or tightening (cumulative).
358    pub glyph_page_evictions: u64,
359    /// Glyph bitmaps rasterized across all windows (cumulative, shared).
360    pub glyphs_rasterized: u64,
361    /// Image bindings retained by the queried window.
362    pub local_image_entries: u64,
363    /// Gradient bindings retained by the queried window.
364    pub local_gradient_entries: u64,
365    /// Glyph page bindings retained by the queried window.
366    pub local_glyph_pages: u64,
367}
368
369/// Optional non-blocking GPU timing sample forwarded by the adapter.
370#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)]
371pub struct GpuSample {
372    pub supported: bool,
373    pub frame: u64,
374    pub main_pass_us: f64,
375}
376
377/// Read-only performance view of one retained window.
378#[derive(Clone, Debug, Default, PartialEq, Serialize)]
379pub struct WindowPerformance {
380    pub requested_frames: u64,
381    pub presented_frames: u64,
382    pub skipped_frames: u64,
383    /// Most recent frame's CPU phase timings (microseconds).
384    pub latest: Option<FrameRecord>,
385    /// Most recent renderer-reported metrics.
386    pub render: RenderFrameMetrics,
387    /// Latest non-blocking GPU timing sample when the adapter supports them.
388    pub gpu: Option<GpuSample>,
389}
390
391/// Machine-readable aggregate of everything the framework measured. Produced
392/// only on demand; building one allocates and is never done in the hot path.
393#[derive(Clone, Debug, Default, PartialEq, Serialize)]
394pub struct PerformanceSnapshot {
395    pub scheduler: SchedulerCounters,
396    pub budget: BudgetStatistics,
397    /// Present-timestamp FPS over the recent window, when presenting.
398    pub fps: Option<f32>,
399    pub windows: Vec<WindowPerformance>,
400    /// Bounded CPU frame-time statistics over retained history, if enabled.
401    pub frame_statistics: FrameStatistics,
402    /// Retained-tree work totals for the primary window's tree.
403    pub widgets: WidgetWorkSnapshot,
404    /// Text layout/shaping cache behavior.
405    pub text: TextCacheSnapshot,
406    /// Accessibility projection behavior.
407    pub accessibility: AccessibilitySnapshot,
408}
409
410/// Subset of retained-tree counters relevant to invalidation contracts.
411#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
412pub struct WidgetWorkSnapshot {
413    pub mounts: u64,
414    pub unmounts: u64,
415    pub rebuilds: u64,
416    pub layouts: u64,
417    pub paints: u64,
418    pub composites: u64,
419    pub animation_ticks: u64,
420    pub scroll_offset_updates: u64,
421    pub reconciliation_fast_paths: u64,
422    pub layout_cache_hits: u64,
423    pub display_lists_reused: u64,
424    pub compositor_only_updates: u64,
425    pub lazy_layouts: u64,
426    pub items_built: u64,
427    pub items_reused: u64,
428    // Task 15 reconciliation breakdown.
429    pub child_list_scans: u64,
430    pub identical_child_bailouts: u64,
431    pub elements_created: u64,
432    pub elements_removed: u64,
433    pub elements_moved: u64,
434    pub dirty_requests: u64,
435    pub dirty_queue_deduplicated: u64,
436    pub elements_total: usize,
437    pub render_objects_total: usize,
438    pub layers_total: usize,
439}
440
441#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
442pub struct TextCacheSnapshot {
443    pub layouts_requested: u64,
444    pub cache_hits: u64,
445    pub cache_misses: u64,
446    /// Paragraphs shaped fresh (document path misses).
447    pub paragraphs_reshaped: u64,
448    /// Paragraph layouts served entirely from cache.
449    pub parley_layouts_reused: u64,
450    /// Multi-paragraph documents composed from retained paragraphs.
451    pub documents_composed: u64,
452}
453
454#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
455pub struct AccessibilitySnapshot {
456    pub nodes_published: u64,
457    pub updates_skipped_unchanged: u64,
458}
459
460/// Shared, observable view of the latest [`PerformanceSnapshot`].
461///
462/// The application publishes throttled snapshots (default at most ~5 Hz and
463/// only when the profiler mode is [`ProfilerMode::Diagnostic`] or higher, so
464/// production frames never build one). Overlay builders read
465/// [`PerformanceHub::version`] during their build; publishing bumps that
466/// version through a `Signal`-compatible counter so only overlay elements
467/// rebuild.
468#[derive(Clone)]
469pub struct PerformanceHub {
470    inner: std::rc::Rc<HubInner>,
471}
472struct HubInner {
473    snapshot: RefCell<PerformanceSnapshot>,
474    /// Bumped on every publish; overlay builders read it during their build
475    /// so publishing invalidates exactly the observing elements.
476    version: super::Signal<u64>,
477    /// Set by the facade when an overlay element subscribes; publishing is
478    /// skipped entirely while nobody observes, keeping idle cost at zero.
479    observed: Cell<bool>,
480    last_publish: Cell<Option<Instant>>,
481}
482impl Default for PerformanceHub {
483    fn default() -> Self {
484        Self {
485            inner: std::rc::Rc::new(HubInner {
486                snapshot: RefCell::new(PerformanceSnapshot::default()),
487                version: super::Signal::new(0),
488                observed: Cell::new(false),
489                last_publish: Cell::new(None),
490            }),
491        }
492    }
493}
494impl PerformanceHub {
495    #[must_use]
496    pub fn new() -> Self {
497        Self::default()
498    }
499    /// Reads the current publish counter. Calling this from a builder
500    /// subscribes that element to future publications.
501    #[must_use]
502    pub fn version(&self) -> u64 {
503        self.inner.version.get()
504    }
505    #[must_use]
506    pub fn observed(&self) -> bool {
507        self.inner.observed.get()
508    }
509    pub fn set_observed(&self, observed: bool) {
510        self.inner.observed.set(observed);
511    }
512    #[must_use]
513    pub fn snapshot(&self) -> PerformanceSnapshot {
514        self.inner.snapshot.borrow().clone()
515    }
516    /// True when enough time has elapsed since the previous publish.
517    #[must_use]
518    pub fn publish_due(&self, min_interval: Duration) -> bool {
519        match self.inner.last_publish.get() {
520            Some(previous) => previous.elapsed() >= min_interval,
521            None => true,
522        }
523    }
524    pub fn publish(&self, snapshot: PerformanceSnapshot) {
525        *self.inner.snapshot.borrow_mut() = snapshot;
526        let next = self.inner.version.get().wrapping_add(1);
527        self.inner.version.set(next);
528        self.inner.last_publish.set(Some(Instant::now()));
529    }
530}
531
532impl PerformanceSnapshot {
533    /// Formats this snapshot into the standard debug-overlay lines.
534    #[must_use]
535    pub fn overlay_lines(&self) -> Vec<String> {
536        let mut lines = Vec::with_capacity(16);
537        lines.push(format!(
538            "FPS {}",
539            self.fps
540                .map(|fps| format!("{fps:.0}"))
541                .unwrap_or_else(|| "idle".into())
542        ));
543        let latest = self.windows.iter().find_map(|window| window.latest);
544        if let Some(record) = latest {
545            let timings = &record.timings;
546            let ms = |micros: u32| f64::from(micros) / 1000.;
547            lines.push(format!("CPU {:>6.2} ms", ms(timings.cpu_total)));
548            match self
549                .windows
550                .iter()
551                .filter_map(|window| window.gpu)
552                .find(|gpu| gpu.supported && gpu.main_pass_us.is_finite())
553            {
554                Some(gpu) => lines.push(format!("GPU {:>6.2} ms", gpu.main_pass_us / 1000.)),
555                None => lines.push("GPU  timing unavailable".into()),
556            }
557            lines.push(format!("Build      {:>7.2}", ms(timings.build)));
558            lines.push(format!("Layout     {:>7.2}", ms(timings.layout)));
559            lines.push(format!("Paint      {:>7.2}", ms(timings.paint)));
560            lines.push(format!("Composite  {:>7.2}", ms(timings.composite)));
561            let render = self
562                .windows
563                .iter()
564                .map(|window| window.render)
565                .max_by_key(|render| render.draw_calls)
566                .unwrap_or_default();
567            lines.push(format!(
568                "Render     {:>7.2}",
569                (f64::from(render.prepare_us) + f64::from(render.encode_us + render.submit_us))
570                    / 1000.
571            ));
572            lines.push(format!("Draws      {:>7}", render.draw_calls));
573            lines.push(format!("Instances  {:>7}", render.instances));
574            lines.push(format!("Passes     {:>7}", render.render_passes));
575            if render.pipelines_created > 0 {
576                lines.push(format!(
577                    "Pipelines  {:>7} (unexpected after init)",
578                    render.pipelines_created
579                ));
580            }
581            if let Some(budget) = self.budget.budget_us {
582                lines.push(format!(
583                    "Budget     {:>6.2} ms ({} over)",
584                    budget / 1000.,
585                    self.budget.frames_over_budget
586                ));
587            }
588        }
589        let widgets = &self.widgets;
590        lines.push(format!("Elements   {:>7}", widgets.elements_total));
591        lines.push(format!(
592            "Built      {:>7}",
593            latest.map_or(0, |record| record.work.updated_elements)
594        ));
595        lines.push(format!(
596            "Layout     {:>7}",
597            latest.map_or(0, |record| record.work.laid_out_render_objects)
598        ));
599        lines.push(format!(
600            "Paint      {:>7}",
601            latest.map_or(0, |record| record.work.repainted_render_objects)
602        ));
603        let requests = self.text.cache_hits + self.text.cache_misses;
604        if requests > 0 {
605            let hit_rate = self.text.cache_hits as f64 / requests as f64 * 100.;
606            lines.push(format!("Text hit   {:>6.1}%", hit_rate));
607        }
608        if self.widgets.child_list_scans > 0 {
609            lines.push(format!(
610                "Scan/Bail  {:>4}/{}",
611                self.widgets.child_list_scans, self.widgets.identical_child_bailouts
612            ));
613        }
614        if self.text.documents_composed > 0 {
615            lines.push(format!(
616                "Para resh  {:>4} ({} docs)",
617                self.text.paragraphs_reshaped, self.text.documents_composed
618            ));
619        }
620        if self.accessibility.updates_skipped_unchanged > 0 {
621            lines.push(format!(
622                "A11y skips {:>7}",
623                self.accessibility.updates_skipped_unchanged
624            ));
625        }
626        lines
627    }
628}