Skip to main content

ios_core/services/instruments/
fps.rs

1use serde::Serialize;
2
3const NANOS_PER_SECOND: f64 = 1_000_000_000.0;
4const NANOS_PER_MILLI: f64 = 1_000_000.0;
5const PENDING_FENCE_TIMESTAMP: i64 = i64::MAX;
6const MOVIE_FRAME_COST_NS: f64 = NANOS_PER_SECOND / 24.0;
7const TWO_FRAME_THRESHOLD_NS: f64 = MOVIE_FRAME_COST_NS * 2.0;
8const THREE_FRAME_THRESHOLD_NS: f64 = MOVIE_FRAME_COST_NS * 3.0;
9const MIN_FRAME_DURATION_NS: i64 = 4_000_000;
10const KPERF_RECORD_SIZE: usize = 64;
11const KDBG_CLASS_MASK: u32 = 0xFF00_0000;
12const KDBG_SUBCLASS_MASK: u32 = 0x00FF_0000;
13const KDBG_CODE_MASK: u32 = 0x0000_FFFC;
14const KDBG_CLASS_OFFSET: u32 = 24;
15const KDBG_SUBCLASS_OFFSET: u32 = 16;
16const KDBG_CODE_OFFSET: u32 = 2;
17const FRAME_COMMIT_CLASS: u32 = 0x31;
18const FRAME_COMMIT_SUBCLASS: u32 = 0x80;
19const FRAME_COMMIT_CODE: u32 = 0xC6;
20
21#[derive(Debug, Clone, PartialEq, Serialize)]
22pub struct FpsSample {
23    pub fps: f64,
24    pub jank: u32,
25    pub big_jank: u32,
26    pub stutter: f64,
27    pub frame_count: u32,
28    pub window_ms: f64,
29}
30
31#[derive(Debug, Clone)]
32pub struct MachTimeInfo {
33    pub numer: u64,
34    pub denom: u64,
35}
36
37#[derive(Debug, Default)]
38pub struct FpsWindowCalculator {
39    last_frame_vsync_time: Option<i64>,
40    last_three_frame_times: Vec<i64>,
41}
42
43impl FpsWindowCalculator {
44    pub fn new() -> Self {
45        Self {
46            last_frame_vsync_time: None,
47            last_three_frame_times: Vec::with_capacity(3),
48        }
49    }
50
51    pub fn push_timestamps(&mut self, timestamps_ns: &[i64]) -> Option<FpsSample> {
52        if timestamps_ns.is_empty() {
53            return None;
54        }
55
56        let mut duration_ns = 0i64;
57        let mut frame_count = 0u32;
58        let mut jank = 0u32;
59        let mut big_jank = 0u32;
60        let mut jank_time_ns = 0i64;
61
62        for &timestamp in timestamps_ns {
63            if timestamp == PENDING_FENCE_TIMESTAMP {
64                continue;
65            }
66            if self
67                .last_frame_vsync_time
68                .is_some_and(|last_timestamp| timestamp <= last_timestamp)
69            {
70                continue;
71            }
72
73            let Some(last_frame_vsync_time) = self.last_frame_vsync_time else {
74                self.last_frame_vsync_time = Some(timestamp);
75                continue;
76            };
77
78            let frame_cost = timestamp - last_frame_vsync_time;
79            if frame_cost < MIN_FRAME_DURATION_NS {
80                continue;
81            }
82
83            duration_ns += frame_cost;
84            frame_count += 1;
85
86            if self.last_three_frame_times.len() > 2 {
87                let last_frame_avg = self.last_three_frame_times.iter().copied().sum::<i64>()
88                    / self.last_three_frame_times.len() as i64;
89
90                if frame_cost > last_frame_avg * 2 {
91                    if (frame_cost as f64) > THREE_FRAME_THRESHOLD_NS {
92                        big_jank += 1;
93                        jank += 1;
94                        jank_time_ns += frame_cost;
95                    } else if (frame_cost as f64) > TWO_FRAME_THRESHOLD_NS {
96                        jank += 1;
97                        jank_time_ns += frame_cost;
98                    }
99                }
100            }
101
102            self.last_three_frame_times.push(frame_cost);
103            if self.last_three_frame_times.len() > 3 {
104                self.last_three_frame_times.remove(0);
105            }
106            self.last_frame_vsync_time = Some(timestamp);
107        }
108
109        if frame_count == 0 || duration_ns <= 0 {
110            return Some(FpsSample {
111                fps: 0.0,
112                jank,
113                big_jank,
114                stutter: 0.0,
115                frame_count,
116                window_ms: round_to(duration_ns as f64 / NANOS_PER_MILLI, 2),
117            });
118        }
119
120        Some(FpsSample {
121            fps: round_to(
122                frame_count as f64 / (duration_ns as f64 / NANOS_PER_SECOND),
123                1,
124            ),
125            jank,
126            big_jank,
127            stutter: round_to(jank_time_ns as f64 / duration_ns as f64, 2),
128            frame_count,
129            window_ms: round_to(duration_ns as f64 / NANOS_PER_MILLI, 2),
130        })
131    }
132}
133
134pub fn parse_frame_commit_timestamps(chunk: &[u8], time_info: &MachTimeInfo) -> Vec<i64> {
135    if time_info.denom == 0 {
136        return Vec::new();
137    }
138
139    chunk
140        .chunks_exact(KPERF_RECORD_SIZE)
141        .filter_map(|record| {
142            let mach_time = u64::from_le_bytes(record[0..8].try_into().ok()?);
143            let debug_id = u32::from_le_bytes(record[48..52].try_into().ok()?);
144            if !is_frame_commit_event(debug_id) {
145                return None;
146            }
147            Some(((mach_time as f64) * (time_info.numer as f64) / (time_info.denom as f64)) as i64)
148        })
149        .collect()
150}
151
152fn is_frame_commit_event(debug_id: u32) -> bool {
153    ((debug_id & KDBG_CLASS_MASK) >> KDBG_CLASS_OFFSET) == FRAME_COMMIT_CLASS
154        && ((debug_id & KDBG_SUBCLASS_MASK) >> KDBG_SUBCLASS_OFFSET) == FRAME_COMMIT_SUBCLASS
155        && ((debug_id & KDBG_CODE_MASK) >> KDBG_CODE_OFFSET) == FRAME_COMMIT_CODE
156}
157
158fn round_to(value: f64, precision: i32) -> f64 {
159    let ratio = 10f64.powi(precision);
160    (value * ratio).round() / ratio
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    const KPERF_RECORD_SIZE: usize = 64;
168    const FRAME_COMMIT_DEBUG_ID: u32 = 0x31800318;
169
170    fn build_record(mach_time: u64, debug_id: u32) -> Vec<u8> {
171        let mut record = vec![0u8; KPERF_RECORD_SIZE];
172        record[0..8].copy_from_slice(&mach_time.to_le_bytes());
173        record[48..52].copy_from_slice(&debug_id.to_le_bytes());
174        record
175    }
176
177    #[test]
178    fn parses_frame_commit_records_into_nanosecond_timestamps() {
179        let mut chunk = Vec::new();
180        chunk.extend_from_slice(&build_record(1_000, FRAME_COMMIT_DEBUG_ID));
181        chunk.extend_from_slice(&build_record(1_500, 0x1234_5678));
182        chunk.extend_from_slice(&build_record(2_000, FRAME_COMMIT_DEBUG_ID));
183
184        let timestamps = parse_frame_commit_timestamps(
185            &chunk,
186            &MachTimeInfo {
187                numer: 125,
188                denom: 3,
189            },
190        );
191
192        assert_eq!(timestamps, vec![41_666, 83_333]);
193    }
194
195    #[test]
196    fn calculates_fps_jank_and_stutter_from_frame_timestamps() {
197        let mut calculator = FpsWindowCalculator::new();
198        let sample = calculator
199            .push_timestamps(&[
200                0,
201                16_000_000,
202                32_000_000,
203                48_000_000,
204                64_000_000,
205                180_000_000,
206            ])
207            .expect("sample should be emitted");
208
209        assert_eq!(sample.frame_count, 5);
210        assert_eq!(sample.window_ms, 180.0);
211        assert_eq!(sample.jank, 1);
212        assert_eq!(sample.big_jank, 0);
213        assert!(
214            (sample.fps - 27.8).abs() < 0.1,
215            "unexpected fps: {}",
216            sample.fps
217        );
218        assert!(sample.stutter > 0.60 && sample.stutter < 0.70);
219    }
220}