1use super::*;
4use objc2_foundation::NSRange;
5use objc2_metal::{
6 MTLCommonCounterSetTimestamp, MTLComputePassDescriptor, MTLCounterSampleBuffer,
7 MTLCounterSampleBufferDescriptor, MTLCounterSamplingPoint, MTLCounterSet, MTLDevice,
8 MTLStorageMode,
9};
10use serde::{Deserialize, Serialize};
11use std::cell::RefCell;
12
13const SECONDS_PER_NANOSECOND: f64 = 1e-9;
14
15#[derive(Debug, Default, Clone, Serialize, Deserialize)]
16#[serde(tag = "status", rename_all = "snake_case")]
17pub enum GpuTiming {
18 #[default]
19 NotInitialized,
20 Available,
21 Unsupported,
22 Disabled {
23 reason: String,
24 },
25 Failed {
26 error: String,
27 },
28}
29
30#[derive(Clone, Copy)]
31pub(super) struct ClockSample {
32 cpu_ns: u64,
33 gpu: u64,
34}
35
36#[derive(Default, Clone, Copy)]
38struct CpuWindow {
39 observed: u64,
40 resident: u64,
41 fetched: u64,
42}
43
44pub(super) enum CpuPhase {
45 Observed,
46 ResidentRelease,
47 FetchedRelease,
48}
49
50pub(super) struct PhaseTimer {
51 buffer: Retained<ProtocolObject<dyn MTLCounterSampleBuffer>>,
52 count: usize,
55 cpu: RefCell<Vec<CpuWindow>>,
56 timebase: MachTimebase,
57}
58
59impl PhaseTimer {
60 pub(super) fn initialize(
61 ctx: &MetalContext,
62 slots: usize,
63 enabled: bool,
64 ) -> (Option<Self>, GpuTiming) {
65 if !enabled {
66 return (
67 None,
68 GpuTiming::Disabled {
69 reason: "diagnostic layer cap changes the compute-pass layout".into(),
70 },
71 );
72 }
73
74 Self::initialization_result(Self::new(ctx, slots))
75 }
76
77 fn initialization_result(result: Result<Option<Self>>) -> (Option<Self>, GpuTiming) {
78 match result {
79 Ok(Some(timer)) => (Some(timer), GpuTiming::Available),
80 Ok(None) => (None, GpuTiming::Unsupported),
81 Err(error) => {
82 let error = format!("{error:#}");
83
84 eprintln!("GPU phase timing unavailable: {error}");
85
86 (None, GpuTiming::Failed { error })
87 }
88 }
89 }
90
91 pub(super) fn new(ctx: &MetalContext, slots: usize) -> Result<Option<Self>> {
92 if !ctx
93 .device
94 .supportsCounterSampling(MTLCounterSamplingPoint::AtStageBoundary)
95 {
96 return Ok(None);
97 }
98
99 let sets = ctx
100 .device
101 .counterSets()
102 .context("Metal counter sets unavailable")?;
103 let set = sets
104 .iter()
105 .find(|set| &*set.name() == unsafe { MTLCommonCounterSetTimestamp })
106 .context("Metal timestamp counter unavailable")?;
107 let descriptor = MTLCounterSampleBufferDescriptor::new();
108
109 descriptor.setCounterSet(Some(&set));
110 descriptor.setStorageMode(MTLStorageMode::Shared);
111
112 let count = 4 * slots + 1;
113
114 unsafe {
115 descriptor.setSampleCount(count);
116 }
117
118 let buffer = ctx
119 .device
120 .newCounterSampleBufferWithDescriptor_error(&descriptor)
121 .map_err(|e| anyhow::anyhow!("timestamp buffer: {e}"))?;
122
123 Ok(Some(Self {
124 buffer,
125 count,
126 cpu: RefCell::new(vec![CpuWindow::default(); slots]),
127 timebase: MachTimebase::new()?,
128 }))
129 }
130
131 fn encoder(
132 &self,
133 cb: &ProtocolObject<dyn MTLCommandBuffer>,
134 start: Option<usize>,
135 end: usize,
136 ) -> Result<Retained<Enc>> {
137 ensure_indices(self.count, start, end)?;
138
139 let descriptor = MTLComputePassDescriptor::new();
140 let attachment = unsafe {
141 descriptor
142 .sampleBufferAttachments()
143 .objectAtIndexedSubscript(0)
144 };
145
146 attachment.setSampleBuffer(Some(&self.buffer));
147
148 unsafe {
149 attachment.setStartOfEncoderSampleIndex(start.unwrap_or(usize::MAX));
150 attachment.setEndOfEncoderSampleIndex(end);
151 }
152
153 cb.computeCommandEncoderWithDescriptor(&descriptor)
154 .context("timed compute encoder")
155 }
156
157 fn timestamps(&self) -> Option<Vec<u64>> {
158 let data = unsafe { self.buffer.resolveCounterRange(NSRange::new(0, self.count)) }?;
159 let bytes = unsafe { data.as_bytes_unchecked() };
160
161 if bytes.len() != self.count * 8 {
162 return None;
163 }
164
165 Some(
166 bytes
167 .as_chunks::<8>()
168 .0
169 .iter()
170 .map(|b| u64::from_ne_bytes(*b))
171 .collect(),
172 )
173 }
174}
175
176fn ensure_indices(count: usize, start: Option<usize>, end: usize) -> Result<()> {
177 anyhow::ensure!(
178 end < count && start.is_none_or(|s| s < count),
179 "timestamp index exceeds buffer"
180 );
181
182 Ok(())
183}
184
185fn sample_clock(ctx: &MetalContext) -> ClockSample {
186 let (mut cpu, mut gpu) = (0, 0);
187
188 unsafe {
189 ctx.device
190 .sampleTimestamps_gpuTimestamp((&mut cpu).into(), (&mut gpu).into());
191 }
192
193 ClockSample { cpu_ns: cpu, gpu }
194}
195
196struct MachTimebase {
197 numerator: u32,
198 denominator: u32,
199}
200
201impl MachTimebase {
202 #[allow(deprecated)]
204 fn new() -> Result<Self> {
205 let mut timebase = libc::mach_timebase_info_data_t { numer: 0, denom: 0 };
206 let status = unsafe { libc::mach_timebase_info(&mut timebase) };
207
208 anyhow::ensure!(
209 status == 0 && timebase.denom != 0,
210 "Mach timebase unavailable"
211 );
212
213 Ok(Self {
214 numerator: timebase.numer,
215 denominator: timebase.denom,
216 })
217 }
218
219 fn nanoseconds(&self, ticks: u64) -> u64 {
220 (u128::from(ticks) * u128::from(self.numerator) / u128::from(self.denominator)) as u64
222 }
223}
224
225pub(super) fn thread_cpu_seconds() -> f64 {
226 let mut time = libc::timespec {
227 tv_sec: 0,
228 tv_nsec: 0,
229 };
230 let status = unsafe { libc::clock_gettime(libc::CLOCK_THREAD_CPUTIME_ID, &mut time) };
231
232 if status != 0 {
233 return 0.0;
234 }
235
236 time.tv_sec as f64 + time.tv_nsec as f64 / 1e9
237}
238
239impl Gpu<'_> {
240 pub(super) fn phase_encoder(
241 &self,
242 cb: &ProtocolObject<dyn MTLCommandBuffer>,
243 start: Option<usize>,
244 end: usize,
245 ) -> Result<Retained<Enc>> {
246 match &self.phase_timer {
247 Some(timer) => timer.encoder(cb, start, end),
248 None => cb.computeCommandEncoder().context("encoder"),
249 }
250 }
251
252 pub(super) fn phase_clock(&self) -> Option<ClockSample> {
253 self.phase_timer.as_ref().map(|_| sample_clock(&self.ctx))
254 }
255
256 #[allow(deprecated)]
257 pub(super) fn phase_cpu_mark(&self, slot: usize, phase: CpuPhase) {
258 let Some(timer) = &self.phase_timer else {
259 return;
260 };
261 let now = timer
262 .timebase
263 .nanoseconds(unsafe { libc::mach_absolute_time() });
264 let mut windows = timer.cpu.borrow_mut();
265
266 match phase {
267 CpuPhase::Observed => windows[slot].observed = now,
268 CpuPhase::ResidentRelease => windows[slot].resident = now,
269 CpuPhase::FetchedRelease => windows[slot].fetched = now,
270 }
271 }
272
273 pub(super) fn collect_trunk_phases(
274 &mut self,
275 layers: usize,
276 mtp: Option<usize>,
277 clock: Option<ClockSample>,
278 ) {
279 let mut slots: Vec<_> = self.layers[..layers]
280 .iter()
281 .enumerate()
282 .map(|(slot, layer)| (slot, layer.moe.record_layer))
283 .collect();
284
285 if let Some(record) = mtp {
286 slots.push((self.layers.len(), record));
287 }
288
289 self.collect_phases(&slots, clock);
290 }
291
292 pub(super) fn collect_phases(&mut self, slots: &[(usize, usize)], before: Option<ClockSample>) {
293 let Some(timer) = &self.phase_timer else {
294 return;
295 };
296 let Some(before) = before else {
297 return;
298 };
299 let after = sample_clock(&self.ctx);
300 let timestamps = timer.timestamps();
301 let scale = clock_scale(before, after);
302
303 for &(slot, layer) in slots {
304 let stats = &mut self.activity.layers[layer].phases;
305 let points = timestamps
306 .as_ref()
307 .and_then(|v| v.get(4 * slot..4 * slot + 5))
308 .filter(|p| p[0] >= before.gpu && p[4] <= after.gpu);
309 let valid = points
310 .zip(scale)
311 .is_some_and(|(p, scale)| accumulate_phases(stats, p, scale));
312
313 if !valid {
314 stats.invalid_gpu_windows += 1;
315 }
316
317 let cpu = timer.cpu.borrow()[slot];
318 stats.cpu_prepare_seconds +=
319 cpu.resident.saturating_sub(cpu.observed) as f64 * SECONDS_PER_NANOSECOND;
320 stats.cpu_after_resident_release_seconds +=
321 cpu.fetched.saturating_sub(cpu.resident) as f64 * SECONDS_PER_NANOSECOND;
322
323 if let Some((p, scale)) = points.zip(scale).filter(|_| valid) {
324 stats.cpu_observation_delay_seconds +=
325 observation_delay(cpu.observed, p[0], before, scale);
326 }
327 }
328 }
329}
330
331fn observation_delay(observed_ns: u64, gpu_start: u64, before: ClockSample, scale: f64) -> f64 {
332 let gpu_offset = signed_delta(gpu_start, before.gpu) * scale;
333 let cpu_offset = signed_delta(observed_ns, before.cpu_ns) * SECONDS_PER_NANOSECOND;
334
335 (cpu_offset - gpu_offset).max(0.0)
336}
337
338fn signed_delta(a: u64, b: u64) -> f64 {
339 if a >= b {
340 return (a - b) as f64;
341 }
342
343 -((b - a) as f64)
344}
345
346fn clock_scale(before: ClockSample, after: ClockSample) -> Option<f64> {
347 let cpu = after.cpu_ns.checked_sub(before.cpu_ns)?;
348 let gpu = after.gpu.checked_sub(before.gpu)?;
349
350 if cpu == 0 || gpu == 0 {
351 return None;
352 }
353
354 Some(cpu as f64 / gpu as f64 * SECONDS_PER_NANOSECOND)
356}
357
358fn accumulate_phases(
359 stats: &mut super::activity::layers::PhaseStats,
360 p: &[u64],
361 scale: f64,
362) -> bool {
363 if p.iter().any(|&t| t == 0 || t == u64::MAX) || p.windows(2).any(|p| p[1] < p[0]) {
364 return false;
365 }
366
367 stats.gpu_windows += 1;
368 stats.router_to_resident_seconds += (p[1] - p[0]) as f64 * scale;
369 stats.resident_seconds += (p[2] - p[1]) as f64 * scale;
370 stats.resident_to_fetched_seconds += (p[3] - p[2]) as f64 * scale;
371 stats.fetched_stage_seconds += (p[4] - p[3]) as f64 * scale;
372
373 true
374}
375
376#[cfg(test)]
377#[path = "../../../tests/unit/qwen4_exp/gpu/phases.rs"]
378mod tests;