Skip to main content

cherenkov/qwen4_exp/gpu/
phases.rs

1//! Timestamp the existing compute passes; resolve only after their normal wait.
2
3use 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/// CPU markers use the same nanosecond timebase as Metal's CPU samples.
37#[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    // Router end, resident start/end, fetched start. The next router end
53    // closes this window and opens the next; the final sample closes the head.
54    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    // libc deprecated its Mach bindings, not the underlying macOS API.
203    #[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        // Widen before multiplication: absolute uptimes can exceed u64 / numer.
221        (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    // Metal's calibrated CPU timestamps are already nanoseconds, not Mach ticks.
355    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;