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}