Skip to main content

qualia_core_db/inference/
inference_gpu_profiler.rs

1//! Per-kernel GPU timing for the native LLM forward/decode path (W2 / D17).
2//!
3//! Wraps each LLM compute pass with `TIMESTAMP_QUERY` writes, resolves the query set,
4//! and accumulates GPU-internal nanoseconds per [`Phase`]. Gated by a runtime flag
5//! (`QUALIA_LLM_GPU_PROFILE=1` or [`set_enabled`]) so production decode pays nothing:
6//! when disabled, the `pass_writes_*` helpers return `None` and the pass is byte-identical
7//! to before (`timestamp_writes: None`).
8//!
9//! Requires the shared device to have negotiated `TIMESTAMP_QUERY`
10//! (see [`crate::gpu_context::SharedGpuContext::timestamps_supported`]); degrades to a
11//! no-op otherwise, so it is safe to call unconditionally from every dispatch site.
12//!
13//! **Honesty note:** the per-phase nanoseconds are GPU-internal (begin→end of the pass) and
14//! individually accurate. A *profiling* run serialises per-op readback, so the headline
15//! tok/s of a profiled run is perturbed — report the per-phase split, not the profiled tok/s.
16//!
17//! Not re-entrant: it uses one shared 2-slot query set, which the single LLM engine thread's
18//! per-op blocking readback serialises. That matches how decode actually runs.
19
20use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
21
22/// Distinct kernel families on the LLM forward/decode path.
23#[repr(usize)]
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Phase {
26    /// Quantized token-embedding lookup/dequant.
27    Embedding = 0,
28    /// Projection + FFN matmuls (incl. the ternary-FFN dispatch).
29    Gemm = 1,
30    /// Fused attention (Q·K, softmax, ·V).
31    Attention = 2,
32    /// Single-pass fused transformer-block shader.
33    FusedBlock = 3,
34    /// lm_head logits + top-k / argmax sampling.
35    OutputTopk = 4,
36}
37
38impl Phase {
39    pub const COUNT: usize = 5;
40    pub const ALL: [Phase; Self::COUNT] = [
41        Phase::Embedding,
42        Phase::Gemm,
43        Phase::Attention,
44        Phase::FusedBlock,
45        Phase::OutputTopk,
46    ];
47
48    #[inline]
49    pub fn label(self) -> &'static str {
50        match self {
51            Phase::Embedding => "embedding",
52            Phase::Gemm => "gemm",
53            Phase::Attention => "attention",
54            Phase::FusedBlock => "fused_block",
55            Phase::OutputTopk => "output_topk",
56        }
57    }
58}
59
60static ENABLED: AtomicBool = AtomicBool::new(false);
61static ACC_NS: [AtomicU64; Phase::COUNT] = [const { AtomicU64::new(0) }; Phase::COUNT];
62static ACC_CALLS: [AtomicU64; Phase::COUNT] = [const { AtomicU64::new(0) }; Phase::COUNT];
63
64/// Force GPU pass profiling on/off at runtime (bench-only; production leaves it off).
65#[inline]
66pub fn set_enabled(on: bool) {
67    ENABLED.store(on, Ordering::Relaxed);
68}
69
70/// One-shot `QUALIA_LLM_GPU_PROFILE` env opt-in (cached; no per-call allocation).
71fn env_opt_in() -> bool {
72    static ENV: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
73    *ENV.get_or_init(|| {
74        std::env::var("QUALIA_LLM_GPU_PROFILE")
75            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
76            .unwrap_or(false)
77    })
78}
79
80/// True when profiling is requested AND the shared device negotiated `TIMESTAMP_QUERY`.
81#[inline]
82pub fn enabled() -> bool {
83    if !(ENABLED.load(Ordering::Relaxed) || env_opt_in()) {
84        return false;
85    }
86    #[cfg(not(target_arch = "wasm32"))]
87    {
88        crate::gpu_context::shared_gpu().timestamps_supported
89    }
90    #[cfg(target_arch = "wasm32")]
91    {
92        false
93    }
94}
95
96// ── Native timestamp resources + accumulation ─────────────────────────────────
97
98#[cfg(not(target_arch = "wasm32"))]
99struct TsResources {
100    qset: wgpu::QuerySet,
101    resolve: wgpu::Buffer,
102    staging: wgpu::Buffer,
103}
104
105#[cfg(not(target_arch = "wasm32"))]
106const TS_BYTES: u64 = 2 * std::mem::size_of::<u64>() as u64; // begin + end
107
108#[cfg(not(target_arch = "wasm32"))]
109fn resources() -> &'static TsResources {
110    use std::sync::OnceLock;
111    static R: OnceLock<TsResources> = OnceLock::new();
112    R.get_or_init(|| {
113        let device = &crate::gpu_context::shared_gpu().device;
114        let qset = device.create_query_set(&wgpu::QuerySetDescriptor {
115            label: Some("llm-ts-qset"),
116            ty: wgpu::QueryType::Timestamp,
117            count: 2,
118        });
119        let resolve = device.create_buffer(&wgpu::BufferDescriptor {
120            label: Some("llm-ts-resolve"),
121            size: TS_BYTES,
122            usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
123            mapped_at_creation: false,
124        });
125        let staging = device.create_buffer(&wgpu::BufferDescriptor {
126            label: Some("llm-ts-staging"),
127            size: TS_BYTES,
128            usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
129            mapped_at_creation: false,
130        });
131        TsResources {
132            qset,
133            resolve,
134            staging,
135        }
136    })
137}
138
139/// `timestamp_writes` for a single-pass kernel (writes both begin and end on this pass).
140/// Returns `None` (zero overhead) when profiling is off — drop straight into the descriptor.
141#[cfg(not(target_arch = "wasm32"))]
142pub fn pass_writes_both() -> Option<wgpu::ComputePassTimestampWrites<'static>> {
143    if !enabled() {
144        return None;
145    }
146    Some(wgpu::ComputePassTimestampWrites {
147        query_set: &resources().qset,
148        beginning_of_pass_write_index: Some(0),
149        end_of_pass_write_index: Some(1),
150    })
151}
152
153/// `timestamp_writes` for the FIRST pass of a multi-pass kernel (begin only).
154#[cfg(not(target_arch = "wasm32"))]
155pub fn pass_writes_begin() -> Option<wgpu::ComputePassTimestampWrites<'static>> {
156    if !enabled() {
157        return None;
158    }
159    Some(wgpu::ComputePassTimestampWrites {
160        query_set: &resources().qset,
161        beginning_of_pass_write_index: Some(0),
162        end_of_pass_write_index: None,
163    })
164}
165
166/// `timestamp_writes` for the LAST pass of a multi-pass kernel (end only).
167#[cfg(not(target_arch = "wasm32"))]
168pub fn pass_writes_end() -> Option<wgpu::ComputePassTimestampWrites<'static>> {
169    if !enabled() {
170        return None;
171    }
172    Some(wgpu::ComputePassTimestampWrites {
173        query_set: &resources().qset,
174        beginning_of_pass_write_index: None,
175        end_of_pass_write_index: Some(1),
176    })
177}
178
179/// Encode the query-set resolve into `encoder` (call after the pass, before `finish()`).
180/// No-op when profiling is off.
181#[cfg(not(target_arch = "wasm32"))]
182pub fn resolve(encoder: &mut wgpu::CommandEncoder) {
183    if !enabled() {
184        return;
185    }
186    let r = resources();
187    encoder.resolve_query_set(&r.qset, 0..2, &r.resolve, 0);
188    encoder.copy_buffer_to_buffer(&r.resolve, 0, &r.staging, 0, TS_BYTES);
189}
190
191/// Read the resolved timestamps and add `end - begin` ns to `phase`'s accumulator.
192/// Call after the kernel's submit + device poll. No-op when profiling is off.
193#[cfg(not(target_arch = "wasm32"))]
194pub fn accumulate(phase: Phase) {
195    if !enabled() {
196        return;
197    }
198    let ctx = crate::gpu_context::shared_gpu();
199    let r = resources();
200    let slice = r.staging.slice(..);
201    let (tx, rx) = std::sync::mpsc::channel();
202    slice.map_async(wgpu::MapMode::Read, move |res| {
203        let _ = tx.send(res);
204    });
205    // poll(Wait) blocks until the GPU is done and drives the map callback.
206    let _ = ctx.device.poll(wgpu::PollType::wait_indefinitely());
207    if rx.recv().map(|r| r.is_ok()).unwrap_or(false) {
208        let data = slice
209            .get_mapped_range()
210            .expect("wgpu buffer map_range failed");
211        let ts: &[u64] = bytemuck::cast_slice(&data);
212        if ts.len() >= 2 {
213            let ticks = ts[1].saturating_sub(ts[0]);
214            let ns = (ticks as f64 * ctx.timestamp_period_ns as f64) as u64;
215            ACC_NS[phase as usize].fetch_add(ns, Ordering::Relaxed);
216            ACC_CALLS[phase as usize].fetch_add(1, Ordering::Relaxed);
217        }
218        drop(data);
219    }
220    r.staging.unmap();
221}
222
223#[cfg(target_arch = "wasm32")]
224pub fn pass_writes_both() -> Option<wgpu::ComputePassTimestampWrites<'static>> {
225    None
226}
227#[cfg(target_arch = "wasm32")]
228pub fn pass_writes_begin() -> Option<wgpu::ComputePassTimestampWrites<'static>> {
229    None
230}
231#[cfg(target_arch = "wasm32")]
232pub fn pass_writes_end() -> Option<wgpu::ComputePassTimestampWrites<'static>> {
233    None
234}
235#[cfg(target_arch = "wasm32")]
236pub fn resolve(_encoder: &mut wgpu::CommandEncoder) {}
237#[cfg(target_arch = "wasm32")]
238pub fn accumulate(_phase: Phase) {}
239
240// ── Snapshot / reset for the bench ────────────────────────────────────────────
241
242/// Accumulated GPU time for one phase across all dispatches since the last [`reset`].
243#[derive(Debug, Clone, Copy)]
244pub struct PhaseTiming {
245    pub phase: Phase,
246    pub total_ns: u64,
247    pub calls: u64,
248}
249
250impl PhaseTiming {
251    #[inline]
252    pub fn micros(&self) -> f64 {
253        self.total_ns as f64 / 1000.0
254    }
255}
256
257/// Snapshot all phase accumulators (for the bench JSON).
258pub fn snapshot() -> [PhaseTiming; Phase::COUNT] {
259    let mut out = [PhaseTiming {
260        phase: Phase::Embedding,
261        total_ns: 0,
262        calls: 0,
263    }; Phase::COUNT];
264    for (i, p) in Phase::ALL.iter().enumerate() {
265        out[i] = PhaseTiming {
266            phase: *p,
267            total_ns: ACC_NS[i].load(Ordering::Relaxed),
268            calls: ACC_CALLS[i].load(Ordering::Relaxed),
269        };
270    }
271    out
272}
273
274/// Zero all phase accumulators (call before a measured run).
275pub fn reset() {
276    for i in 0..Phase::COUNT {
277        ACC_NS[i].store(0, Ordering::Relaxed);
278        ACC_CALLS[i].store(0, Ordering::Relaxed);
279    }
280}
281
282/// True if any phase recorded GPU time since the last reset (test/diagnostic helper).
283pub fn any_recorded() -> bool {
284    (0..Phase::COUNT).any(|i| ACC_CALLS[i].load(Ordering::Relaxed) > 0)
285}
286
287#[cfg(all(test, not(target_arch = "wasm32")))]
288mod tests {
289    use super::*;
290
291    // Trivial compute kernel with enough arithmetic to register measurable GPU time.
292    const SELF_TEST_WGSL: &str = r#"
293@group(0) @binding(0) var<storage, read_write> data: array<u32>;
294@compute @workgroup_size(64)
295fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
296    let i = gid.x;
297    if (i < arrayLength(&data)) {
298        var acc = data[i];
299        for (var k: u32 = 0u; k < 4096u; k = k + 1u) {
300            acc = acc * 1664525u + 1013904223u;
301        }
302        data[i] = acc;
303    }
304}
305"#;
306
307    /// Proves the end-to-end timestamp path on real hardware: enable → wrap a real
308    /// compute pass → resolve → accumulate → assert non-zero GPU time. Named with `gpu`
309    /// so `--skip gpu` excludes it on adapters/CI without a timestamp-capable device.
310    #[test]
311    fn gpu_timestamp_self_test_records_nonzero() {
312        let ctx = crate::gpu_context::shared_gpu();
313        if !ctx.timestamps_supported {
314            eprintln!("SKIP gpu_timestamp_self_test: adapter has no TIMESTAMP_QUERY");
315            return;
316        }
317        let device = &ctx.device;
318        let queue = &ctx.queue;
319
320        set_enabled(true);
321        reset();
322
323        let n: u64 = 65_536;
324        let buf = device.create_buffer(&wgpu::BufferDescriptor {
325            label: Some("prof-self-test-buf"),
326            size: n * 4,
327            usage: wgpu::BufferUsages::STORAGE,
328            mapped_at_creation: false,
329        });
330        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
331            label: Some("prof-self-test-shader"),
332            source: wgpu::ShaderSource::Wgsl(SELF_TEST_WGSL.into()),
333        });
334        let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
335            label: Some("prof-self-test-pipe"),
336            layout: None,
337            module: &shader,
338            entry_point: Some("main"),
339            compilation_options: wgpu::PipelineCompilationOptions::default(),
340            cache: None,
341        });
342        let bgl = pipeline.get_bind_group_layout(0);
343        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
344            label: Some("prof-self-test-bg"),
345            layout: &bgl,
346            entries: &[wgpu::BindGroupEntry {
347                binding: 0,
348                resource: buf.as_entire_binding(),
349            }],
350        });
351
352        let mut encoder =
353            device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
354        {
355            let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
356                label: Some("prof-self-test-pass"),
357                timestamp_writes: pass_writes_both(),
358            });
359            cpass.set_pipeline(&pipeline);
360            cpass.set_bind_group(0, &bind_group, &[]);
361            cpass.dispatch_workgroups((n as u32 + 63) / 64, 1, 1);
362        }
363        resolve(&mut encoder);
364        queue.submit(Some(encoder.finish()));
365        accumulate(Phase::Gemm);
366
367        assert!(
368            any_recorded(),
369            "profiler recorded no timestamps on a TIMESTAMP_QUERY device"
370        );
371        let snap = snapshot();
372        let gemm = snap
373            .iter()
374            .find(|t| matches!(t.phase, Phase::Gemm))
375            .copied()
376            .expect("gemm phase present");
377        eprintln!(
378            "gpu_timestamp_self_test: gemm pass = {:.3} µs ({} ns, {} call)",
379            gemm.micros(),
380            gemm.total_ns,
381            gemm.calls
382        );
383        assert_eq!(gemm.calls, 1, "expected exactly one recorded pass");
384        assert!(
385            gemm.total_ns > 0,
386            "expected non-zero GPU time for a 4096-iter kernel"
387        );
388
389        set_enabled(false);
390    }
391
392    #[test]
393    fn disabled_profiler_is_noop() {
394        set_enabled(false);
395        reset();
396        // With profiling off, the pass-writes helpers must return None (zero overhead).
397        assert!(pass_writes_both().is_none());
398        assert!(pass_writes_begin().is_none());
399        assert!(pass_writes_end().is_none());
400        accumulate(Phase::Attention);
401        assert!(!any_recorded());
402    }
403}