Skip to main content

oxigdal_gpu/
profiling.rs

1//! GPU timestamp profiling using `wgpu::Features::TIMESTAMP_QUERY`.
2//!
3//! Provides an opt-in [`GpuTimestampProfiler`] that records GPU-side
4//! timestamps around compute passes via a [`wgpu::QuerySet`] and reports
5//! per-pass timings in microseconds after resolution.
6//!
7//! The profiler is **graceful**: [`GpuTimestampProfiler::try_new`] returns
8//! `None` whenever the adapter does not advertise the required features
9//! ([`wgpu::Features::TIMESTAMP_QUERY`] **and**
10//! [`wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS`] — the latter is
11//! required to call [`wgpu::CommandEncoder::write_timestamp`] outside of a
12//! render/compute pass).  Callers that do not enable these features when
13//! constructing the [`GpuContext`] will simply receive `None` and can fall
14//! back to wall-clock timing.
15//!
16//! # Quick sketch
17//!
18//! ```rust,no_run
19//! use oxigdal_gpu::{GpuContext, GpuTimestampProfiler};
20//! # async fn run(ctx: &GpuContext) -> Result<(), Box<dyn std::error::Error>> {
21//! let mut prof = match GpuTimestampProfiler::try_new(ctx, 16) {
22//!     Some(p) => p,
23//!     None => return Ok(()), // adapter lacks TIMESTAMP_QUERY support
24//! };
25//! let mut encoder = ctx.device().create_command_encoder(&Default::default());
26//! if let Some(slot) = prof.begin_pass(&mut encoder, "blur") {
27//!     // ... record compute work into `encoder` ...
28//!     prof.end_pass(&mut encoder, slot);
29//! }
30//! ctx.queue().submit([encoder.finish()]);
31//! for t in prof.resolve(ctx)? {
32//!     println!("{} took {:.3} us", t.label, t.duration_us);
33//! }
34//! # Ok(()) }
35//! ```
36//!
37//! # Notes on wgpu features
38//!
39//! - [`wgpu::Features::TIMESTAMP_QUERY`] is the WebGPU baseline feature that
40//!   enables creating [`wgpu::QueryType::Timestamp`] query sets and using
41//!   them within render/compute pass *descriptors*.
42//! - [`wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS`] is the native-only
43//!   extension that allows calling
44//!   [`wgpu::CommandEncoder::write_timestamp`] **between** passes.  This is
45//!   the API used by [`GpuTimestampProfiler::begin_pass`] /
46//!   [`GpuTimestampProfiler::end_pass`].
47//!
48//! Both features must be requested via
49//! [`GpuContextConfig::with_features`][crate::context::GpuContextConfig::with_features]
50//! before creating the [`GpuContext`].
51
52use crate::context::GpuContext;
53use crate::error::{GpuError, GpuResult};
54
55/// Bytes per timestamp value resolved into the destination buffer.
56///
57/// WGPU writes one `u64` per timestamp into a [`wgpu::BufferUsages::QUERY_RESOLVE`]
58/// buffer, so the byte stride is always `8` (see
59/// [`wgpu::QUERY_SIZE`]).
60const TIMESTAMP_BYTES: u64 = 8;
61
62/// Profiled timing for a single GPU pass.
63///
64/// The `start_ns` / `end_ns` fields are the absolute timestamp values
65/// converted to nanoseconds (multiplied by
66/// [`wgpu::Queue::get_timestamp_period`]).  They have no anchor and should
67/// only be compared **within** the same profiler instance / submission.
68///
69/// `duration_us` is precomputed as `(end_ns - start_ns) / 1000.0` for
70/// convenience.  Negative differences cannot occur because the underlying
71/// subtraction uses saturating arithmetic on `u64`.
72#[derive(Debug, Clone, PartialEq)]
73pub struct PassTiming {
74    /// User-supplied label for the pass.
75    pub label: String,
76    /// Start timestamp, converted to nanoseconds.
77    pub start_ns: u64,
78    /// End timestamp, converted to nanoseconds.
79    pub end_ns: u64,
80    /// Pass duration in microseconds: `(end_ns − start_ns) / 1000.0`.
81    pub duration_us: f64,
82}
83
84/// Opt-in GPU timestamp profiler backed by a single [`wgpu::QuerySet`].
85///
86/// Each pass consumes two adjacent timestamp slots (start, end).  After
87/// recording, call [`Self::resolve`] to copy the query results into a
88/// staging buffer, map it, and decode the raw `u64` timestamps into
89/// [`PassTiming`] structs.
90///
91/// See the module-level documentation for usage.
92pub struct GpuTimestampProfiler {
93    /// `true` if the underlying adapter supports timestamp queries and the
94    /// profiler is recording GPU work; `false` for the test-only
95    /// [`Self::dummy`] constructor.
96    enabled: bool,
97    /// Total number of timestamp slots in the query set (always even).
98    capacity: u32,
99    /// Index of the next free slot.  Incremented by 2 on each [`Self::begin_pass`].
100    next_slot: u32,
101    /// Nanoseconds per timestamp tick, from [`wgpu::Queue::get_timestamp_period`].
102    period_ns: f32,
103    /// One entry per recorded pass (indexed by `slot / 2`).
104    labels: Vec<String>,
105    /// WGPU query set holding the timestamps (None for [`Self::dummy`]).
106    query_set: Option<wgpu::QuerySet>,
107    /// Buffer that receives the resolved query data on the GPU.
108    resolve_buffer: Option<wgpu::Buffer>,
109    /// Staging buffer that the CPU maps to read the timestamps back.
110    staging_buffer: Option<wgpu::Buffer>,
111}
112
113impl std::fmt::Debug for GpuTimestampProfiler {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        f.debug_struct("GpuTimestampProfiler")
116            .field("enabled", &self.enabled)
117            .field("capacity", &self.capacity)
118            .field("next_slot", &self.next_slot)
119            .field("period_ns", &self.period_ns)
120            .field("labels", &self.labels)
121            .field("has_query_set", &self.query_set.is_some())
122            .field("has_resolve_buffer", &self.resolve_buffer.is_some())
123            .field("has_staging_buffer", &self.staging_buffer.is_some())
124            .finish()
125    }
126}
127
128impl GpuTimestampProfiler {
129    /// Attempt to construct a profiler with `capacity` timestamp slots.
130    ///
131    /// `capacity` is rounded **up** to the nearest even number (and clamped
132    /// to a minimum of 2) because each pass uses one start slot and one end
133    /// slot.
134    ///
135    /// Returns `None` if the adapter does not support both
136    /// [`wgpu::Features::TIMESTAMP_QUERY`] and
137    /// [`wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS`].  Callers must
138    /// enable these features in the
139    /// [`crate::context::GpuContextConfig`] used to create the context
140    /// (see the module-level docs).
141    pub fn try_new(ctx: &GpuContext, capacity: u32) -> Option<Self> {
142        let features = ctx.device().features();
143        if !features.contains(wgpu::Features::TIMESTAMP_QUERY) {
144            return None;
145        }
146        if !features.contains(wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS) {
147            return None;
148        }
149
150        // Round up to even, with a minimum of 2 (one start + one end slot).
151        let capacity = capacity.max(2);
152        let capacity = if capacity % 2 == 0 {
153            capacity
154        } else {
155            capacity.saturating_add(1)
156        };
157
158        let period_ns = ctx.queue().get_timestamp_period();
159
160        let query_set = ctx.device().create_query_set(&wgpu::QuerySetDescriptor {
161            label: Some("oxigdal_profiler_timestamps"),
162            count: capacity,
163            ty: wgpu::QueryType::Timestamp,
164        });
165
166        let byte_size = (capacity as u64).saturating_mul(TIMESTAMP_BYTES);
167
168        let resolve_buffer = ctx.device().create_buffer(&wgpu::BufferDescriptor {
169            label: Some("oxigdal_profiler_resolve"),
170            size: byte_size,
171            usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
172            mapped_at_creation: false,
173        });
174
175        let staging_buffer = ctx.device().create_buffer(&wgpu::BufferDescriptor {
176            label: Some("oxigdal_profiler_staging"),
177            size: byte_size,
178            usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
179            mapped_at_creation: false,
180        });
181
182        Some(Self {
183            enabled: true,
184            capacity,
185            next_slot: 0,
186            period_ns,
187            labels: Vec::new(),
188            query_set: Some(query_set),
189            resolve_buffer: Some(resolve_buffer),
190            staging_buffer: Some(staging_buffer),
191        })
192    }
193
194    /// Test-only constructor that allocates no GPU resources.
195    ///
196    /// Useful for unit tests that need to exercise the `PassTiming` types
197    /// or the disabled-path branches without touching wgpu.  All recording
198    /// methods become no-ops and [`Self::resolve`] returns an empty vector.
199    pub fn dummy(period_ns: f32) -> Self {
200        Self {
201            enabled: false,
202            capacity: 0,
203            next_slot: 0,
204            period_ns,
205            labels: Vec::new(),
206            query_set: None,
207            resolve_buffer: None,
208            staging_buffer: None,
209        }
210    }
211
212    /// Returns `true` if this profiler is actively recording GPU timestamps.
213    pub fn is_enabled(&self) -> bool {
214        self.enabled
215    }
216
217    /// Timestamp period in nanoseconds per tick, as reported by
218    /// [`wgpu::Queue::get_timestamp_period`].
219    pub fn period_ns(&self) -> f32 {
220        self.period_ns
221    }
222
223    /// Total number of timestamp slots available in the query set.
224    pub fn capacity(&self) -> u32 {
225        self.capacity
226    }
227
228    /// Index of the next free start slot.  Always even when the profiler is
229    /// in a consistent recording state.
230    pub fn next_slot(&self) -> u32 {
231        self.next_slot
232    }
233
234    /// Records the **start** timestamp for a pass labelled `label`.
235    ///
236    /// Returns the slot index that was used (which is also the index of the
237    /// start–end pair).  Pass this value to [`Self::end_pass`] to record the
238    /// matching end timestamp.
239    ///
240    /// Returns `None` when:
241    /// * the profiler is disabled (constructed via [`Self::dummy`]), or
242    /// * the query set capacity has been exhausted.
243    pub fn begin_pass(&mut self, encoder: &mut wgpu::CommandEncoder, label: &str) -> Option<u32> {
244        if !self.enabled {
245            return None;
246        }
247        if self.next_slot.saturating_add(1) >= self.capacity {
248            return None;
249        }
250        let qs = self.query_set.as_ref()?;
251        let slot = self.next_slot;
252        encoder.write_timestamp(qs, slot);
253        self.labels.push(label.to_string());
254        self.next_slot = self.next_slot.saturating_add(2);
255        Some(slot)
256    }
257
258    /// Records the **end** timestamp for a pass at `start_slot + 1`.
259    ///
260    /// Silently no-ops when the profiler is disabled or has no query set.
261    /// The caller is responsible for passing the slot index returned by the
262    /// matching [`Self::begin_pass`] call.
263    pub fn end_pass(&self, encoder: &mut wgpu::CommandEncoder, start_slot: u32) {
264        if !self.enabled {
265            return;
266        }
267        if let Some(qs) = &self.query_set {
268            encoder.write_timestamp(qs, start_slot.saturating_add(1));
269        }
270    }
271
272    /// Resolves all recorded timestamps and decodes them into per-pass
273    /// [`PassTiming`] entries.
274    ///
275    /// Steps:
276    /// 1. Encodes a `resolve_query_set` + `copy_buffer_to_buffer` command
277    ///    sequence to move the timestamps from the GPU-side query set into
278    ///    a CPU-mappable staging buffer.
279    /// 2. Submits the command buffer.
280    /// 3. Maps the staging buffer for reading, blocking on
281    ///    [`wgpu::Device::poll`] until the mapping completes.
282    /// 4. Converts each raw `u64` tick to nanoseconds using
283    ///    [`Self::period_ns`] and computes `duration_us`.
284    ///
285    /// Returns an empty `Vec` if the profiler is disabled or no passes were
286    /// recorded.
287    ///
288    /// # Errors
289    ///
290    /// Returns [`GpuError::ExecutionFailed`] when the device poll, the
291    /// buffer mapping callback, or the channel that delivers the mapping
292    /// result fails.
293    pub fn resolve(&mut self, ctx: &GpuContext) -> GpuResult<Vec<PassTiming>> {
294        if !self.enabled || self.next_slot == 0 {
295            return Ok(Vec::new());
296        }
297
298        let (Some(qs), Some(resolve_buf), Some(staging_buf)) = (
299            self.query_set.as_ref(),
300            self.resolve_buffer.as_ref(),
301            self.staging_buffer.as_ref(),
302        ) else {
303            return Ok(Vec::new());
304        };
305
306        let recorded_slots = self.next_slot;
307        let recorded_bytes = (recorded_slots as u64).saturating_mul(TIMESTAMP_BYTES);
308
309        // 1. Encode resolve + copy into the staging buffer.
310        let mut encoder = ctx
311            .device()
312            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
313                label: Some("oxigdal_profiler_resolve_encoder"),
314            });
315        encoder.resolve_query_set(qs, 0..recorded_slots, resolve_buf, 0);
316        encoder.copy_buffer_to_buffer(resolve_buf, 0, staging_buf, 0, recorded_bytes);
317        ctx.queue().submit([encoder.finish()]);
318
319        // 2. Map the staging buffer synchronously.
320        let slice = staging_buf.slice(0..recorded_bytes);
321        let (tx, rx) = std::sync::mpsc::channel();
322        slice.map_async(wgpu::MapMode::Read, move |result| {
323            let _ = tx.send(result);
324        });
325        ctx.device()
326            .poll(wgpu::PollType::wait_indefinitely())
327            .map_err(|e| {
328                GpuError::execution_failed(format!(
329                    "profiler: device poll while mapping staging buffer failed: {e:?}"
330                ))
331            })?;
332        rx.recv()
333            .map_err(|e| {
334                GpuError::execution_failed(format!(
335                    "profiler: mapping callback channel closed: {e}"
336                ))
337            })?
338            .map_err(|e| {
339                GpuError::execution_failed(format!("profiler: map_async failed: {e:?}"))
340            })?;
341
342        // 3. Decode raw u64 timestamps.
343        let data = slice.get_mapped_range();
344        let raw_ts: Vec<u64> = data
345            .chunks_exact(TIMESTAMP_BYTES as usize)
346            .map(|c| u64::from_ne_bytes([c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7]]))
347            .collect();
348        drop(data);
349        staging_buf.unmap();
350
351        // 4. Build PassTiming entries.
352        let mut out = Vec::with_capacity(self.labels.len());
353        for (i, label) in self.labels.iter().enumerate() {
354            let start_raw = raw_ts.get(i * 2).copied().unwrap_or(0);
355            let end_raw = raw_ts.get(i * 2 + 1).copied().unwrap_or(0);
356            let duration_ns = end_raw.saturating_sub(start_raw) as f64 * self.period_ns as f64;
357            let start_ns = (start_raw as f64 * self.period_ns as f64) as u64;
358            let end_ns = (end_raw as f64 * self.period_ns as f64) as u64;
359            out.push(PassTiming {
360                label: label.clone(),
361                start_ns,
362                end_ns,
363                duration_us: duration_ns / 1000.0,
364            });
365        }
366        Ok(out)
367    }
368
369    /// Returns the recorded pass labels in registration order.
370    pub fn pass_labels(&self) -> &[String] {
371        &self.labels
372    }
373
374    /// Clears all recorded labels and resets the next-slot pointer back to
375    /// zero, allowing the same query-set capacity to be re-used for another
376    /// round of measurements.
377    ///
378    /// Note that the underlying [`wgpu::QuerySet`] storage is **not**
379    /// re-allocated; previously written timestamps remain in the resolve
380    /// buffer until overwritten by subsequent `write_timestamp` calls.
381    pub fn reset(&mut self) {
382        self.labels.clear();
383        self.next_slot = 0;
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[test]
392    fn dummy_profiler_has_no_query_set() {
393        let prof = GpuTimestampProfiler::dummy(1.0);
394        assert!(!prof.is_enabled());
395        assert_eq!(prof.capacity(), 0);
396        assert_eq!(prof.next_slot(), 0);
397        assert_eq!(prof.pass_labels().len(), 0);
398        // None of the wgpu resources should be allocated.
399        assert!(prof.query_set.is_none());
400        assert!(prof.resolve_buffer.is_none());
401        assert!(prof.staging_buffer.is_none());
402    }
403
404    #[test]
405    fn dummy_profiler_reset_is_idempotent() {
406        let mut prof = GpuTimestampProfiler::dummy(2.0);
407        prof.reset();
408        prof.reset();
409        assert_eq!(prof.next_slot(), 0);
410        assert!(prof.pass_labels().is_empty());
411    }
412
413    #[test]
414    fn pass_timing_construction() {
415        let t = PassTiming {
416            label: "blur".to_string(),
417            start_ns: 1_000,
418            end_ns: 2_500,
419            duration_us: 1.5,
420        };
421        assert_eq!(t.label, "blur");
422        assert_eq!(t.end_ns - t.start_ns, 1_500);
423        assert!((t.duration_us - 1.5).abs() < f64::EPSILON);
424    }
425
426    #[test]
427    fn debug_impl_does_not_panic() {
428        let prof = GpuTimestampProfiler::dummy(1.0);
429        let s = format!("{:?}", prof);
430        assert!(s.contains("GpuTimestampProfiler"));
431    }
432}