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