Skip to main content

wgpu_core/timestamp_normalization/
mod.rs

1//! Utility for normalizing GPU timestamp queries to have a consistent
2//! 1GHz period. This uses a compute shader to do the normalization,
3//! so the timestamps exist in their correct format on the GPU, as
4//! is required by the WebGPU specification.
5//!
6//! ## Algorithm
7//!
8//! The fundamental operation is multiplying a u64 timestamp by an f32
9//! value. We have neither f64s nor u64s in shaders, so we need to do
10//! something more complicated.
11//!
12//! We first decompose the f32 into a u32 fraction where the denominator
13//! is a power of two. We do the computation with f64 for ease of computation,
14//! as those can store u32s losslessly.
15//!
16//! Because the denominator is a power of two, this means the shader can evaluate
17//! this divide by using a shift. Additionally, we always choose the largest denominator
18//! we can, so that the fraction is as precise as possible.
19//!
20//! To evaluate this function, we have two helper operations (both in common.wgsl).
21//!
22//! 1. `u64_mul_u32` multiplies a u64 by a u32 and returns a u96.
23//! 2. `shift_right_u96` shifts a u96 right by a given amount, returning a u96.
24//!
25//! See their implementations for more details.
26//!
27//! We then multiply the timestamp by the numerator, and shift it right by the
28//! denominator. This gives us the normalized timestamp.
29
30use core::num::NonZeroU64;
31
32use alloc::{boxed::Box, string::String, sync::Arc};
33
34use hashbrown::HashMap;
35
36use crate::{
37    device::{Device, DeviceError},
38    hal_label,
39    pipeline::{CreatePipelineError, CreateShaderModuleError},
40    resource::Buffer,
41    snatch::SnatchGuard,
42    track::BufferTracker,
43};
44
45pub const TIMESTAMP_NORMALIZATION_BUFFER_USES: wgt::BufferUses =
46    wgt::BufferUses::STORAGE_READ_WRITE;
47
48struct InternalState {
49    temporary_bind_group_layout: Box<dyn hal::DynBindGroupLayout>,
50    pipeline_layout: Box<dyn hal::DynPipelineLayout>,
51    pipeline: Box<dyn hal::DynComputePipeline>,
52}
53
54#[derive(Debug, Clone, thiserror::Error)]
55pub enum TimestampNormalizerInitError {
56    #[error("Failed to initialize bind group layout")]
57    BindGroupLayout(#[source] DeviceError),
58    #[cfg(feature = "wgsl")]
59    #[error("Failed to parse shader")]
60    ParseWgsl(#[source] naga::error::ShaderError<naga::front::wgsl::ParseError>),
61    #[error("Failed to validate shader module")]
62    ValidateWgsl(#[source] naga::error::ShaderError<naga::WithSpan<naga::valid::ValidationError>>),
63    #[error("Failed to create shader module")]
64    CreateShaderModule(#[from] CreateShaderModuleError),
65    #[error("Failed to create pipeline layout")]
66    PipelineLayout(#[source] DeviceError),
67    #[error("Failed to create compute pipeline")]
68    ComputePipeline(#[from] CreatePipelineError),
69}
70
71/// Normalizes GPU timestamps to have a consistent 1GHz period.
72/// See module documentation for more information.
73pub struct TimestampNormalizer {
74    state: Option<InternalState>,
75}
76
77impl TimestampNormalizer {
78    /// Creates a new timestamp normalizer.
79    ///
80    /// If the device cannot support automatic timestamp normalization,
81    /// this will return a normalizer that does nothing.
82    ///
83    /// # Errors
84    ///
85    /// If any resources are invalid, this will return an error.
86    pub fn new(
87        device: &Device,
88        timestamp_period: f32,
89    ) -> Result<Self, TimestampNormalizerInitError> {
90        unsafe {
91            if !device
92                .instance_flags
93                .contains(wgt::InstanceFlags::AUTOMATIC_TIMESTAMP_NORMALIZATION)
94            {
95                return Ok(Self { state: None });
96            }
97
98            if !device
99                .downlevel
100                .flags
101                .contains(wgt::DownlevelFlags::COMPUTE_SHADERS)
102            {
103                log::error!("Automatic timestamp normalization was requested, but compute shaders are not supported.");
104                return Ok(Self { state: None });
105            }
106
107            if timestamp_period == 1.0 {
108                // If the period is 1, we don't need to do anything to them.
109                return Ok(Self { state: None });
110            }
111
112            let temporary_bind_group_layout = device
113                .raw()
114                .create_bind_group_layout(&hal::BindGroupLayoutDescriptor {
115                    label: hal_label(
116                        Some("(wgpu internal) Timestamp Normalization Bind Group Layout"),
117                        device.instance_flags,
118                    ),
119                    flags: hal::BindGroupLayoutFlags::empty(),
120                    entries: &[wgt::BindGroupLayoutEntry {
121                        binding: 0,
122                        visibility: wgt::ShaderStages::COMPUTE,
123                        ty: wgt::BindingType::Buffer {
124                            ty: wgt::BufferBindingType::Storage { read_only: false },
125                            has_dynamic_offset: false,
126                            min_binding_size: Some(NonZeroU64::new(8).unwrap()),
127                        },
128                        count: None,
129                    }],
130                })
131                .map_err(|e| {
132                    TimestampNormalizerInitError::BindGroupLayout(device.handle_hal_error(e))
133                })?;
134
135            let common_src = include_str!("common.wgsl");
136            let src = include_str!("timestamp_normalization.wgsl");
137
138            let preprocessed_src = alloc::format!("{common_src}\n{src}");
139
140            #[cfg(feature = "wgsl")]
141            let module = naga::front::wgsl::parse_str(&preprocessed_src).map_err(|inner| {
142                TimestampNormalizerInitError::ParseWgsl(naga::error::ShaderError {
143                    source: preprocessed_src.clone(),
144                    label: None,
145                    inner: Box::new(inner),
146                })
147            })?;
148            #[cfg(not(feature = "wgsl"))]
149            #[allow(clippy::diverging_sub_expression)]
150            let module =
151                panic!("Timestamp normalization requires the wgsl feature flag to be enabled!");
152
153            let info = crate::device::create_validator(
154                wgt::Features::IMMEDIATES,
155                wgt::DownlevelFlags::empty(),
156                naga::valid::ValidationFlags::all(),
157            )
158            .validate(&module)
159            .map_err(|inner| {
160                TimestampNormalizerInitError::ValidateWgsl(naga::error::ShaderError {
161                    source: preprocessed_src.clone(),
162                    label: None,
163                    inner,
164                })
165            })?;
166            let hal_shader = hal::ShaderInput::Naga(hal::NagaShader {
167                module: alloc::borrow::Cow::Owned(module),
168                info,
169                debug_source: None,
170            });
171            let hal_desc = hal::ShaderModuleDescriptor {
172                label: hal_label(
173                    Some("(wgpu internal) Timestamp normalizer shader module"),
174                    device.instance_flags,
175                ),
176                runtime_checks: wgt::ShaderRuntimeChecks::unchecked(),
177            };
178            let module = device
179                .raw()
180                .create_shader_module(&hal_desc, hal_shader)
181                .map_err(|error| match error {
182                    hal::ShaderError::Device(error) => {
183                        CreateShaderModuleError::Device(device.handle_hal_error(error))
184                    }
185                    hal::ShaderError::Compilation(ref msg) => {
186                        log::error!("Shader error: {msg}");
187                        CreateShaderModuleError::Generation
188                    }
189                })?;
190
191            let pipeline_layout = device
192                .raw()
193                .create_pipeline_layout(&hal::PipelineLayoutDescriptor {
194                    label: hal_label(
195                        Some("(wgpu internal) Timestamp normalizer pipeline layout"),
196                        device.instance_flags,
197                    ),
198                    bind_group_layouts: &[Some(temporary_bind_group_layout.as_ref())],
199                    immediate_size: 8,
200                    flags: hal::PipelineLayoutFlags::empty(),
201                })
202                .map_err(|e| {
203                    TimestampNormalizerInitError::PipelineLayout(device.handle_hal_error(e))
204                })?;
205
206            let (multiplier, shift) = compute_timestamp_period(timestamp_period);
207
208            let mut constants = HashMap::with_capacity(2);
209            constants.insert(String::from("TIMESTAMP_PERIOD_MULTIPLY"), multiplier as f64);
210            constants.insert(String::from("TIMESTAMP_PERIOD_SHIFT"), shift as f64);
211
212            let pipeline_desc = hal::ComputePipelineDescriptor {
213                label: hal_label(
214                    Some("(wgpu internal) Timestamp normalizer pipeline"),
215                    device.instance_flags,
216                ),
217                layout: pipeline_layout.as_ref(),
218                stage: hal::ProgrammableStage {
219                    module: module.as_ref(),
220                    entry_point: "main",
221                    constants: &constants,
222                    zero_initialize_workgroup_memory: false,
223                },
224                cache: None,
225            };
226            let pipeline = device
227                .raw()
228                .create_compute_pipeline(&pipeline_desc)
229                .map_err(|error| device.handle_hal_pipeline_error(error))?;
230
231            Ok(Self {
232                state: Some(InternalState {
233                    temporary_bind_group_layout,
234                    pipeline_layout,
235                    pipeline,
236                }),
237            })
238        }
239    }
240
241    /// Create a bind group for normalizing timestamps in `buffer`.
242    ///
243    /// `buffer_size` must be 4B-aligned, so that it is valid as a storage binding,
244    /// must be the same or more than the application-visible buffer size, and must
245    /// not overrun the buffer. This function is unsafe because it does not know and
246    /// cannot check whether `buffer_size` meets these requirements.
247    ///
248    /// The buffer size passed here is used to construct a binding that must be
249    /// able to access the full application-visible range of the buffer. It is
250    /// not used to check the destination range of query resolution requests. That
251    /// uses the application-requested buffer size, obtained from the `wgpu_core`
252    /// `Buffer`.
253    pub unsafe fn create_normalization_bind_group(
254        &self,
255        device: &Device,
256        buffer: &dyn hal::DynBuffer,
257        buffer_label: Option<&str>,
258        buffer_size: wgt::BufferSize,
259        buffer_usages: wgt::BufferUsages,
260    ) -> Result<TimestampNormalizationBindGroup, DeviceError> {
261        unsafe {
262            let Some(ref state) = &self.state else {
263                return Ok(TimestampNormalizationBindGroup { raw: None });
264            };
265
266            if !buffer_usages.contains(wgt::BufferUsages::QUERY_RESOLVE) {
267                return Ok(TimestampNormalizationBindGroup { raw: None });
268            }
269
270            // If this buffer is large enough that we wouldn't be able to bind the entire thing
271            // at once to normalize the timestamps, we can't use it. We force the buffer to fail
272            // to allocate. The lowest max binding size is 128MB, and query sets must be small
273            // (no more than 4096), so this should never be hit in practice by sane programs.
274            if buffer_size.get() > device.adapter.limits().max_storage_buffer_binding_size {
275                return Err(DeviceError::OutOfMemory);
276            }
277
278            let bg_label_alloc;
279            let label = match buffer_label {
280                Some(label) => {
281                    bg_label_alloc = alloc::format!("Timestamp normalization bind group ({label})");
282                    &*bg_label_alloc
283                }
284                None => "Timestamp normalization bind group",
285            };
286
287            let bg = device
288                .raw()
289                .create_bind_group(&hal::BindGroupDescriptor {
290                    label: hal_label(Some(label), device.instance_flags),
291                    layout: &*state.temporary_bind_group_layout,
292                    buffers: &[hal::BufferBinding::new_unchecked(buffer, 0, buffer_size)],
293                    samplers: &[],
294                    textures: &[],
295                    acceleration_structures: &[],
296                    external_textures: &[],
297                    entries: &[hal::BindGroupEntry {
298                        binding: 0,
299                        resource_index: 0,
300                        count: 1,
301                    }],
302                })
303                .map_err(|e| device.handle_hal_error(e))?;
304
305            Ok(TimestampNormalizationBindGroup { raw: Some(bg) })
306        }
307    }
308
309    pub fn normalize(
310        &self,
311        snatch_guard: &SnatchGuard<'_>,
312        encoder: &mut dyn hal::DynCommandEncoder,
313        tracker: &mut BufferTracker,
314        bind_group: &TimestampNormalizationBindGroup,
315        buffer: &Arc<Buffer>,
316        buffer_offset_bytes: u64,
317        total_timestamps: u32,
318    ) {
319        let Some(ref state) = &self.state else {
320            return;
321        };
322
323        let Some(bind_group) = bind_group.raw.as_deref() else {
324            return;
325        };
326
327        let buffer_offset_timestamps: u32 = (buffer_offset_bytes / 8).try_into().unwrap(); // Unreachable as MAX_QUERIES is way less than u32::MAX
328
329        let pending_barrier = tracker.set_single(buffer, wgt::BufferUses::STORAGE_READ_WRITE);
330
331        let barrier = pending_barrier.map(|pending| pending.into_hal(buffer, snatch_guard));
332
333        let needed_workgroups = total_timestamps.div_ceil(64);
334
335        unsafe {
336            encoder.transition_buffers(barrier.as_slice());
337            encoder.begin_compute_pass(&hal::ComputePassDescriptor {
338                label: hal_label(
339                    Some("(wgpu internal) Timestamp normalization pass"),
340                    buffer.device.instance_flags,
341                ),
342                timestamp_writes: None,
343            });
344            encoder.set_compute_pipeline(&*state.pipeline);
345            encoder.set_bind_group(&*state.pipeline_layout, 0, bind_group, &[]);
346            encoder.set_immediates(
347                &*state.pipeline_layout,
348                0,
349                &[buffer_offset_timestamps, total_timestamps],
350            );
351            encoder.dispatch_workgroups([needed_workgroups, 1, 1]);
352            encoder.end_compute_pass();
353        }
354    }
355
356    pub fn dispose(self, device: &dyn hal::DynDevice) {
357        unsafe {
358            let Some(state) = self.state else {
359                return;
360            };
361
362            device.destroy_compute_pipeline(state.pipeline);
363            device.destroy_pipeline_layout(state.pipeline_layout);
364            device.destroy_bind_group_layout(state.temporary_bind_group_layout);
365        }
366    }
367
368    pub fn enabled(&self) -> bool {
369        self.state.is_some()
370    }
371}
372
373#[derive(Debug)]
374pub struct TimestampNormalizationBindGroup {
375    raw: Option<Box<dyn hal::DynBindGroup>>,
376}
377
378impl TimestampNormalizationBindGroup {
379    pub fn dispose(self, device: &dyn hal::DynDevice) {
380        unsafe {
381            if let Some(raw) = self.raw {
382                device.destroy_bind_group(raw);
383            }
384        }
385    }
386}
387
388fn compute_timestamp_period(input: f32) -> (u32, u32) {
389    #[cfg(not(feature = "std"))]
390    use num_traits::float::Float as _;
391
392    let pow2 = input.log2().ceil() as i32;
393    let clamped_pow2 = pow2.clamp(-32, 32).unsigned_abs();
394    let shift = 32 - clamped_pow2;
395
396    let denominator = (1u64 << shift) as f64;
397
398    // float -> int conversions are defined to saturate.
399    let multiplier = (input as f64 * denominator).round() as u32;
400
401    (multiplier, shift)
402}
403
404#[cfg(test)]
405mod tests {
406    use core::f64;
407
408    fn assert_timestamp_case(input: f32) {
409        let (multiplier, shift) = super::compute_timestamp_period(input);
410
411        let output = multiplier as f64 / (1u64 << shift) as f64;
412
413        assert!((input as f64 - output).abs() < 0.0000001);
414    }
415
416    #[test]
417    fn compute_timestamp_period() {
418        assert_timestamp_case(0.01);
419        assert_timestamp_case(0.5);
420        assert_timestamp_case(1.0);
421        assert_timestamp_case(2.0);
422        assert_timestamp_case(2.7);
423        assert_timestamp_case(1000.7);
424    }
425}