ios_core/services/instruments/
fps.rs1use 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 ×tamp 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}