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`].
45    pub total: u64,
46}
47
48impl FrameStats {
49    pub fn push(&mut self, mut sample: FrameSample) {
50        sample.input_ms += self.pending_input_ms;
51        self.pending_input_ms = 0.0;
52        self.total += 1;
53        if self.samples.len() < STATS_CAPACITY {
54            self.samples.push(sample);
55        } else {
56            self.samples[self.head] = sample;
57        }
58        self.head = (self.head + 1) % STATS_CAPACITY;
59    }
60
61    /// How many samples the ring holds: climbs to [`STATS_CAPACITY`] and
62    /// stays there. The count of frames is [`total`](Self::total).
63    pub fn len(&self) -> usize {
64        self.samples.len()
65    }
66
67    pub fn is_empty(&self) -> bool {
68        self.samples.is_empty()
69    }
70
71    /// Samples oldest -> newest.
72    pub fn iter(&self) -> impl Iterator<Item = FrameSample> + '_ {
73        let (tail, front) = if self.samples.len() < STATS_CAPACITY {
74            (&self.samples[..], &[][..])
75        } else {
76            let (a, b) = self.samples.split_at(self.head);
77            (b, a)
78        };
79        tail.iter().chain(front.iter()).copied()
80    }
81
82    pub fn last(&self) -> Option<FrameSample> {
83        if self.samples.is_empty() {
84            return None;
85        }
86        let i = (self.head + STATS_CAPACITY - 1) % STATS_CAPACITY;
87        self.samples.get(i).or(self.samples.last()).copied()
88    }
89
90    pub fn avg_total(&self) -> f32 {
91        if self.samples.is_empty() {
92            return 0.0;
93        }
94        self.samples.iter().map(FrameSample::total).sum::<f32>() / self.samples.len() as f32
95    }
96
97    pub fn max_total(&self) -> f32 {
98        self.samples
99            .iter()
100            .map(FrameSample::total)
101            .fold(0.0, f32::max)
102    }
103
104    pub fn avg_work(&self) -> f32 {
105        if self.samples.is_empty() {
106            return 0.0;
107        }
108        self.samples.iter().map(FrameSample::work).sum::<f32>() / self.samples.len() as f32
109    }
110
111    pub fn max_work(&self) -> f32 {
112        self.samples
113            .iter()
114            .map(FrameSample::work)
115            .fold(0.0, f32::max)
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    fn sample(ms: f32) -> FrameSample {
124        FrameSample {
125            view_ms: ms,
126            ..Default::default()
127        }
128    }
129
130    #[test]
131    fn ring_wraps_and_iterates_in_order() {
132        let mut s = FrameStats::default();
133        for i in 0..(STATS_CAPACITY + 10) {
134            s.push(sample(i as f32));
135        }
136        assert_eq!(s.len(), STATS_CAPACITY);
137        // The ring saturates; the total does not (F62).
138        assert_eq!(s.total, (STATS_CAPACITY + 10) as u64);
139        let v: Vec<f32> = s.iter().map(|f| f.view_ms).collect();
140        assert_eq!(v.first().copied(), Some(10.0)); // oldest surviving
141        assert_eq!(v.last().copied(), Some((STATS_CAPACITY + 9) as f32));
142        assert_eq!(s.last().unwrap().view_ms, (STATS_CAPACITY + 9) as f32);
143        // Strictly increasing: ring order is preserved.
144        assert!(v.windows(2).all(|w| w[1] > w[0]));
145    }
146
147    #[test]
148    fn pending_input_folds_into_next_sample() {
149        let mut s = FrameStats {
150            pending_input_ms: 0.5,
151            ..Default::default()
152        };
153        s.push(FrameSample {
154            view_ms: 1.0,
155            ..Default::default()
156        });
157        let last = s.last().unwrap();
158        assert_eq!(last.input_ms, 0.5);
159        assert_eq!(s.pending_input_ms, 0.0);
160        assert!((last.total() - 1.5).abs() < 1e-6);
161    }
162
163    #[test]
164    fn aggregates() {
165        let mut s = FrameStats::default();
166        s.push(sample(1.0));
167        s.push(sample(3.0));
168        assert_eq!(s.avg_total(), 2.0);
169        assert_eq!(s.max_total(), 3.0);
170    }
171}