Skip to main content

kui_core/
stats.rs

1//! Frame timing samples for the latency graph. The core only stores data —
2//! whoever drives the frame loop (the runner, an FFI host) measures and
3//! pushes; `widgets::latency_graph` renders it with ordinary primitives.
4
5/// One frame's cost in milliseconds, split by phase.
6#[derive(Clone, Copy, Debug, Default, PartialEq)]
7pub struct FrameSample {
8    /// Input handling since the previous frame (event routing + edits).
9    pub input_ms: f32,
10    /// Host + extension view() calls (tree building).
11    pub view_ms: f32,
12    /// Layout, text measurement, and display-list emission.
13    pub layout_ms: f32,
14    /// GPU encode + present (actual work).
15    pub render_ms: f32,
16    /// Blocked waiting for a swapchain image (vsync backpressure) — real
17    /// latency, but pacing rather than work.
18    pub wait_ms: f32,
19}
20
21impl FrameSample {
22    pub fn total(&self) -> f32 {
23        self.input_ms + self.view_ms + self.layout_ms + self.render_ms + self.wait_ms
24    }
25
26    /// Time the app is responsible for (everything except vsync pacing).
27    pub fn work(&self) -> f32 {
28        self.input_ms + self.view_ms + self.layout_ms + self.render_ms
29    }
30}
31
32pub const STATS_CAPACITY: usize = 120;
33
34/// Fixed-size ring of recent frame samples, and the count of every frame
35/// ever pushed through it.
36#[derive(Default)]
37pub struct FrameStats {
38    samples: Vec<FrameSample>,
39    head: usize,
40    /// Input time accumulated since the last push (events between frames).
41    pub pending_input_ms: f32,
42    /// Every frame pushed since the core was made — monotonic, where
43    /// [`len`](Self::len) is the ring's fill and saturates at
44    /// [`STATS_CAPACITY`] (backlog F62: a test reading the ring's length
45    /// as a total was reading the wrong number after 120 frames).
46    pub total: u64,
47}
48
49impl FrameStats {
50    pub fn push(&mut self, mut sample: FrameSample) {
51        sample.input_ms += self.pending_input_ms;
52        self.pending_input_ms = 0.0;
53        self.total += 1;
54        if self.samples.len() < STATS_CAPACITY {
55            self.samples.push(sample);
56        } else {
57            self.samples[self.head] = sample;
58        }
59        self.head = (self.head + 1) % STATS_CAPACITY;
60    }
61
62    /// How many samples the ring holds: climbs to [`STATS_CAPACITY`] and
63    /// stays there. The count of frames is [`total`](Self::total).
64    pub fn len(&self) -> usize {
65        self.samples.len()
66    }
67
68    pub fn is_empty(&self) -> bool {
69        self.samples.is_empty()
70    }
71
72    /// Samples oldest -> newest.
73    pub fn iter(&self) -> impl Iterator<Item = FrameSample> + '_ {
74        let (tail, front) = if self.samples.len() < STATS_CAPACITY {
75            (&self.samples[..], &[][..])
76        } else {
77            let (a, b) = self.samples.split_at(self.head);
78            (b, a)
79        };
80        tail.iter().chain(front.iter()).copied()
81    }
82
83    pub fn last(&self) -> Option<FrameSample> {
84        if self.samples.is_empty() {
85            return None;
86        }
87        let i = (self.head + STATS_CAPACITY - 1) % STATS_CAPACITY;
88        self.samples.get(i).or(self.samples.last()).copied()
89    }
90
91    pub fn avg_total(&self) -> f32 {
92        if self.samples.is_empty() {
93            return 0.0;
94        }
95        self.samples.iter().map(FrameSample::total).sum::<f32>() / self.samples.len() as f32
96    }
97
98    pub fn max_total(&self) -> f32 {
99        self.samples
100            .iter()
101            .map(FrameSample::total)
102            .fold(0.0, f32::max)
103    }
104
105    pub fn avg_work(&self) -> f32 {
106        if self.samples.is_empty() {
107            return 0.0;
108        }
109        self.samples.iter().map(FrameSample::work).sum::<f32>() / self.samples.len() as f32
110    }
111
112    pub fn max_work(&self) -> f32 {
113        self.samples
114            .iter()
115            .map(FrameSample::work)
116            .fold(0.0, f32::max)
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    fn sample(ms: f32) -> FrameSample {
125        FrameSample {
126            view_ms: ms,
127            ..Default::default()
128        }
129    }
130
131    #[test]
132    fn ring_wraps_and_iterates_in_order() {
133        let mut s = FrameStats::default();
134        for i in 0..(STATS_CAPACITY + 10) {
135            s.push(sample(i as f32));
136        }
137        assert_eq!(s.len(), STATS_CAPACITY);
138        // The ring saturates; the total does not (F62).
139        assert_eq!(s.total, (STATS_CAPACITY + 10) as u64);
140        let v: Vec<f32> = s.iter().map(|f| f.view_ms).collect();
141        assert_eq!(v.first().copied(), Some(10.0)); // oldest surviving
142        assert_eq!(v.last().copied(), Some((STATS_CAPACITY + 9) as f32));
143        assert_eq!(s.last().unwrap().view_ms, (STATS_CAPACITY + 9) as f32);
144        // Strictly increasing: ring order is preserved.
145        assert!(v.windows(2).all(|w| w[1] > w[0]));
146    }
147
148    #[test]
149    fn pending_input_folds_into_next_sample() {
150        let mut s = FrameStats {
151            pending_input_ms: 0.5,
152            ..Default::default()
153        };
154        s.push(FrameSample {
155            view_ms: 1.0,
156            ..Default::default()
157        });
158        let last = s.last().unwrap();
159        assert_eq!(last.input_ms, 0.5);
160        assert_eq!(s.pending_input_ms, 0.0);
161        assert!((last.total() - 1.5).abs() < 1e-6);
162    }
163
164    #[test]
165    fn aggregates() {
166        let mut s = FrameStats::default();
167        s.push(sample(1.0));
168        s.push(sample(3.0));
169        assert_eq!(s.avg_total(), 2.0);
170        assert_eq!(s.max_total(), 3.0);
171    }
172}