From fa0db6ba4e863fad05baf08ef0a61d9affa489c1 Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh Date: Tue, 7 Jul 2026 15:59:05 -0600 Subject: [PATCH 1/4] perf: add wgpu timestamp phase queries --- .../graphics/backend/wgpu_backend/frame.rs | 31 +- .../graphics/backend/wgpu_backend/init.rs | 10 +- .../libs/graphics/backend/wgpu_backend/mod.rs | 3 + .../backend/wgpu_backend/shadow_pass.rs | 5 +- .../backend/wgpu_backend/timestamps.rs | 449 ++++++++++++++++++ goud_engine/src/libs/graphics/frame_timing.rs | 14 + goud_engine/tests/spec.rs | 2 + .../spec/eng2_p0_01_gpu_timestamp_queries.rs | 38 ++ 8 files changed, 545 insertions(+), 7 deletions(-) create mode 100644 goud_engine/src/libs/graphics/backend/wgpu_backend/timestamps.rs create mode 100644 goud_engine/tests/spec/eng2_p0_01_gpu_timestamp_queries.rs diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/frame.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/frame.rs index 3cd36334..813f614d 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/frame.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/frame.rs @@ -12,6 +12,10 @@ impl FrameOps for WgpuBackend { crate::libs::profiling::profile_scope!(WGPU_BEGIN_FRAME); frame_timing::reset_timings(); + if let Some(gpu_timestamps) = self.gpu_timestamps.as_mut() { + gpu_timestamps.poll_ready_results(&self.device); + gpu_timestamps.record_latest_timings(); + } let surface = match self.surface.as_ref() { Some(s) => s, @@ -63,6 +67,7 @@ impl FrameOps for WgpuBackend { crate::libs::profiling::finish_frame!(); return Err(GoudError::InvalidState("No active frame".into())); }; + let timestamp_readback_slot; let mut encoder = self .device @@ -159,7 +164,10 @@ impl FrameOps for WgpuBackend { }), stencil_ops: None, }), - timestamp_writes: None, + timestamp_writes: self + .gpu_timestamps + .as_ref() + .map(|gpu_timestamps| gpu_timestamps.render_pass_writes()), occlusion_query_set: None, multiview_mask: None, }); @@ -275,10 +283,16 @@ impl FrameOps for WgpuBackend { { crate::libs::profiling::profile_scope!(WGPU_GPU_SUBMIT); + timestamp_readback_slot = self + .gpu_timestamps + .as_mut() + .and_then(|gpu_timestamps| gpu_timestamps.resolve_into_readback(&mut encoder)); self.queue.submit(std::iter::once(encoder.finish())); } let submit_us = submit_start.elapsed().as_micros() as u64; - frame_timing::record_phase("gpu_submit", submit_us); + if self.gpu_timestamps.is_none() { + frame_timing::record_phase("gpu_submit", submit_us); + } // -- readback_stall phase --------------------------------------------- let readback_start = std::time::Instant::now(); @@ -295,10 +309,16 @@ impl FrameOps for WgpuBackend { { crate::libs::profiling::profile_scope!(WGPU_GPU_SUBMIT); + timestamp_readback_slot = self + .gpu_timestamps + .as_mut() + .and_then(|gpu_timestamps| gpu_timestamps.resolve_into_readback(&mut encoder)); self.queue.submit(std::iter::once(encoder.finish())); } let submit_us = submit_start.elapsed().as_micros() as u64; - frame_timing::record_phase("gpu_submit", submit_us); + if self.gpu_timestamps.is_none() { + frame_timing::record_phase("gpu_submit", submit_us); + } self.last_frame_readback = None; } @@ -308,6 +328,11 @@ impl FrameOps for WgpuBackend { frame.surface_texture.present(); let present_us = present_start.elapsed().as_micros() as u64; frame_timing::record_phase("surface_present", present_us); + if let (Some(gpu_timestamps), Some(slot)) = + (self.gpu_timestamps.as_mut(), timestamp_readback_slot) + { + gpu_timestamps.begin_readback(slot); + } crate::libs::profiling::finish_frame!(); diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/init.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/init.rs index 28ecfacf..d7c76bef 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/init.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/init.rs @@ -1,8 +1,8 @@ //! wgpu backend initialization: device/surface setup and public accessors. use super::{ - BackendCapabilities, BackendInfo, BlendFactor, CullFace, DepthFunc, FrontFace, HashMap, - PrimitiveTopology, ShaderLanguage, TextureOps, WgpuBackend, + timestamps::GpuTimestampQueries, BackendCapabilities, BackendInfo, BlendFactor, CullFace, + DepthFunc, FrontFace, HashMap, PrimitiveTopology, ShaderLanguage, TextureOps, WgpuBackend, }; use crate::core::{ error::{GoudError, GoudResult}, @@ -40,10 +40,11 @@ impl WgpuBackend { .await .map_err(|e| GoudError::BackendNotSupported(format!("No suitable GPU adapter: {e}")))?; + let timestamp_features = GpuTimestampQueries::requested_features(adapter.features()); let (device, queue): (wgpu::Device, wgpu::Queue) = adapter .request_device(&wgpu::DeviceDescriptor { label: Some("GoudEngine"), - required_features: wgpu::Features::empty(), + required_features: timestamp_features, required_limits: wgpu::Limits::default(), ..Default::default() }) @@ -241,6 +242,8 @@ impl WgpuBackend { }) }; + let gpu_timestamps = GpuTimestampQueries::new(&device, &queue, device.features()); + Ok(Self { info, device, @@ -307,6 +310,7 @@ impl WgpuBackend { scratch_shadow_pipeline_keys: Vec::new(), scratch_shadow_offsets: Vec::new(), scratch_shadow_grown_shaders: rustc_hash::FxHashSet::default(), + gpu_timestamps, }) } diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/mod.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/mod.rs index c5f04a67..387b1389 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/mod.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/mod.rs @@ -45,6 +45,7 @@ mod switch_init; #[cfg(feature = "switch-vulkan")] pub(crate) mod switch_surface; mod texture; +mod timestamps; mod uniforms; #[cfg(feature = "xbox-gdk")] mod xbox_init; @@ -57,6 +58,7 @@ use resources::{ }; pub use init::{MAX_TEXTURE_UNITS, UNIFORM_BUFFER_SIZE}; +pub use timestamps::{probe_gpu_timestamp_queries, GpuTimestampProbeReport}; /// Whether to request the wgpu software fallback adapter. /// @@ -197,6 +199,7 @@ pub struct WgpuBackend { /// Scratch set reused each frame to dedupe shadow uniform-buffer growth /// per shader, avoiding a per-frame FxHashSet allocation. scratch_shadow_grown_shaders: rustc_hash::FxHashSet, + gpu_timestamps: Option, } // SAFETY: wgpu Device and Queue are Send+Sync. Surface is Send. diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/shadow_pass.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/shadow_pass.rs index aa8bc51f..79441cfb 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/shadow_pass.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/shadow_pass.rs @@ -386,7 +386,10 @@ impl WgpuBackend { }), stencil_ops: None, }), - timestamp_writes: None, + timestamp_writes: self + .gpu_timestamps + .as_ref() + .map(|gpu_timestamps| gpu_timestamps.shadow_pass_writes()), occlusion_query_set: None, multiview_mask: None, }); diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/timestamps.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/timestamps.rs new file mode 100644 index 00000000..52a70756 --- /dev/null +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/timestamps.rs @@ -0,0 +1,449 @@ +use std::sync::mpsc::{self, Receiver, TryRecvError}; + +use crate::libs::graphics::frame_timing; + +const TIMESTAMP_QUERY_COUNT: u32 = 6; +const SHADOW_BEGIN_QUERY: u32 = 0; +const SHADOW_END_QUERY: u32 = 1; +const RENDER_BEGIN_QUERY: u32 = 2; +const RENDER_END_QUERY: u32 = 3; +const SUBMIT_BEGIN_QUERY: u32 = 4; +const SUBMIT_END_QUERY: u32 = 5; +const TIMESTAMP_BUFFER_SIZE: u64 = + (TIMESTAMP_QUERY_COUNT as u64) * std::mem::size_of::() as u64; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) struct GpuTimestampFrameTimings { + pub(super) shadow_us: u64, + pub(super) render_us: u64, + pub(super) submit_us: u64, +} + +#[derive(Debug)] +struct PendingTimestampReadback { + receiver: Receiver>, +} + +#[derive(Debug)] +struct TimestampReadbackSlot { + buffer: wgpu::Buffer, + pending: Option, +} + +#[derive(Debug)] +pub(super) struct GpuTimestampQueries { + query_set: wgpu::QuerySet, + resolve_buffer: wgpu::Buffer, + readback_slots: [TimestampReadbackSlot; 2], + timestamp_period_ns: f32, + latest: GpuTimestampFrameTimings, + next_slot: usize, +} + +/// Result of the headless timestamp-query probe used by the ENG2 spec test. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GpuTimestampProbeReport { + /// Whether the selected adapter exposes the timestamp-query feature set. + pub supported: bool, + /// Raw timestamp slots resolved from the GPU query set. + pub raw_queries: [u64; TIMESTAMP_QUERY_COUNT as usize], +} + +impl GpuTimestampQueries { + pub(super) fn requested_features(adapter_features: wgpu::Features) -> wgpu::Features { + let required = wgpu::Features::TIMESTAMP_QUERY + | wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS + | wgpu::Features::TIMESTAMP_QUERY_INSIDE_PASSES; + + if adapter_features.contains(required) { + required + } else { + wgpu::Features::empty() + } + } + + pub(super) fn new( + device: &wgpu::Device, + queue: &wgpu::Queue, + enabled_features: wgpu::Features, + ) -> Option { + let required = Self::requested_features(enabled_features); + if required.is_empty() { + return None; + } + + let query_set = device.create_query_set(&wgpu::QuerySetDescriptor { + label: Some("goud-gpu-timestamps"), + ty: wgpu::QueryType::Timestamp, + count: TIMESTAMP_QUERY_COUNT, + }); + let resolve_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("goud-gpu-timestamp-resolve"), + size: TIMESTAMP_BUFFER_SIZE, + usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + let readback_slots = std::array::from_fn(|index| TimestampReadbackSlot { + buffer: device.create_buffer(&wgpu::BufferDescriptor { + label: Some(match index { + 0 => "goud-gpu-timestamp-readback-a", + _ => "goud-gpu-timestamp-readback-b", + }), + size: TIMESTAMP_BUFFER_SIZE, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }), + pending: None, + }); + + Some(Self { + query_set, + resolve_buffer, + readback_slots, + timestamp_period_ns: queue.get_timestamp_period(), + latest: GpuTimestampFrameTimings::default(), + next_slot: 0, + }) + } + + pub(super) fn shadow_pass_writes(&self) -> wgpu::RenderPassTimestampWrites<'_> { + wgpu::RenderPassTimestampWrites { + query_set: &self.query_set, + beginning_of_pass_write_index: Some(SHADOW_BEGIN_QUERY), + end_of_pass_write_index: Some(SHADOW_END_QUERY), + } + } + + pub(super) fn render_pass_writes(&self) -> wgpu::RenderPassTimestampWrites<'_> { + wgpu::RenderPassTimestampWrites { + query_set: &self.query_set, + beginning_of_pass_write_index: Some(RENDER_BEGIN_QUERY), + end_of_pass_write_index: Some(RENDER_END_QUERY), + } + } + + pub(super) fn resolve_into_readback( + &mut self, + encoder: &mut wgpu::CommandEncoder, + ) -> Option { + let slot_index = self.next_available_slot()?; + let slot = &self.readback_slots[slot_index]; + + // wgpu timestamps can only measure work inside the GPU command stream, not the + // CPU-side queue.submit() call itself. We therefore treat gpu_submit as the + // submission tail: query resolution plus the copy into the MAP_READ buffer. + encoder.write_timestamp(&self.query_set, SUBMIT_BEGIN_QUERY); + encoder.resolve_query_set( + &self.query_set, + 0..TIMESTAMP_QUERY_COUNT, + &self.resolve_buffer, + 0, + ); + encoder.copy_buffer_to_buffer( + &self.resolve_buffer, + 0, + &slot.buffer, + 0, + TIMESTAMP_BUFFER_SIZE, + ); + encoder.write_timestamp(&self.query_set, SUBMIT_END_QUERY); + + self.next_slot = (slot_index + 1) % self.readback_slots.len(); + Some(slot_index) + } + + pub(super) fn begin_readback(&mut self, slot_index: usize) { + let Some(slot) = self.readback_slots.get_mut(slot_index) else { + return; + }; + if slot.pending.is_some() { + return; + } + + let buffer_slice = slot.buffer.slice(..); + let (tx, rx) = mpsc::channel(); + buffer_slice.map_async(wgpu::MapMode::Read, move |result| { + let _ = tx.send(result); + }); + slot.pending = Some(PendingTimestampReadback { receiver: rx }); + } + + pub(super) fn poll_ready_results(&mut self, device: &wgpu::Device) { + let has_pending = self + .readback_slots + .iter() + .any(|slot| slot.pending.is_some()); + if !has_pending { + return; + } + + let _ = device.poll(wgpu::PollType::Poll); + + for slot in &mut self.readback_slots { + let Some(pending) = slot.pending.take() else { + continue; + }; + + match pending.receiver.try_recv() { + Ok(Ok(())) => { + if let Some(timings) = + read_timestamp_timings(&slot.buffer, self.timestamp_period_ns) + { + self.latest = timings; + } + slot.buffer.unmap(); + } + Ok(Err(_)) | Err(TryRecvError::Disconnected) => { + slot.buffer.unmap(); + } + Err(TryRecvError::Empty) => { + slot.pending = Some(pending); + } + } + } + } + + pub(super) fn record_latest_timings(&self) { + frame_timing::record_phase("gpu_shadow", self.latest.shadow_us); + frame_timing::record_phase("gpu_render", self.latest.render_us); + frame_timing::record_phase("gpu_submit", self.latest.submit_us); + } + + fn next_available_slot(&self) -> Option { + (0..self.readback_slots.len()) + .map(|offset| (self.next_slot + offset) % self.readback_slots.len()) + .find(|&index| self.readback_slots[index].pending.is_none()) + } +} + +fn read_timestamp_timings( + buffer: &wgpu::Buffer, + timestamp_period_ns: f32, +) -> Option { + let mapped = buffer.slice(..).get_mapped_range(); + let raw = bytemuck::cast_slice::(&mapped); + if raw.len() < TIMESTAMP_QUERY_COUNT as usize { + return None; + } + + let timings = GpuTimestampFrameTimings { + shadow_us: raw_timestamp_delta_us( + raw[SHADOW_BEGIN_QUERY as usize], + raw[SHADOW_END_QUERY as usize], + timestamp_period_ns, + ), + render_us: raw_timestamp_delta_us( + raw[RENDER_BEGIN_QUERY as usize], + raw[RENDER_END_QUERY as usize], + timestamp_period_ns, + ), + submit_us: raw_timestamp_delta_us( + raw[SUBMIT_BEGIN_QUERY as usize], + raw[SUBMIT_END_QUERY as usize], + timestamp_period_ns, + ), + }; + drop(mapped); + Some(timings) +} + +fn raw_timestamp_delta_us(start: u64, end: u64, timestamp_period_ns: f32) -> u64 { + if end <= start { + return 0; + } + + let delta_ticks = end - start; + ((delta_ticks as f64) * (timestamp_period_ns as f64) / 1_000.0) as u64 +} + +/// Runs a headless wgpu timestamp-query pass pair and resolves the raw query slots. +pub fn probe_gpu_timestamp_queries() -> Result { + pollster::block_on(probe_gpu_timestamp_queries_async()) +} + +async fn probe_gpu_timestamp_queries_async() -> Result { + let instance = wgpu::Instance::default(); + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::LowPower, + compatible_surface: None, + force_fallback_adapter: super::force_fallback_adapter(), + }) + .await + .map_err(|e| format!("No suitable headless adapter: {e}"))?; + + let features = GpuTimestampQueries::requested_features(adapter.features()); + if features.is_empty() { + return Ok(GpuTimestampProbeReport { + supported: false, + raw_queries: [0; TIMESTAMP_QUERY_COUNT as usize], + }); + } + + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("goud-gpu-timestamp-probe"), + required_features: features, + required_limits: wgpu::Limits::default(), + ..Default::default() + }) + .await + .map_err(|e| format!("Failed to create probe device: {e}"))?; + + let query_set = device.create_query_set(&wgpu::QuerySetDescriptor { + label: Some("goud-gpu-timestamp-probe"), + ty: wgpu::QueryType::Timestamp, + count: TIMESTAMP_QUERY_COUNT, + }); + let resolve_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("goud-gpu-timestamp-probe-resolve"), + size: TIMESTAMP_BUFFER_SIZE, + usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + let readback_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("goud-gpu-timestamp-probe-readback"), + size: TIMESTAMP_BUFFER_SIZE, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + + let shadow_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("goud-gpu-timestamp-probe-shadow"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Depth32Float, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let shadow_view = shadow_texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let color_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("goud-gpu-timestamp-probe-color"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let color_view = color_texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let depth_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("goud-gpu-timestamp-probe-depth"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Depth32Float, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let depth_view = depth_texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("goud-gpu-timestamp-probe"), + }); + { + let _shadow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("goud-gpu-timestamp-probe-shadow"), + color_attachments: &[], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &shadow_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: Some(wgpu::RenderPassTimestampWrites { + query_set: &query_set, + beginning_of_pass_write_index: Some(SHADOW_BEGIN_QUERY), + end_of_pass_write_index: Some(SHADOW_END_QUERY), + }), + occlusion_query_set: None, + multiview_mask: None, + }); + } + { + let _render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("goud-gpu-timestamp-probe-render"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &color_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: Some(wgpu::RenderPassTimestampWrites { + query_set: &query_set, + beginning_of_pass_write_index: Some(RENDER_BEGIN_QUERY), + end_of_pass_write_index: Some(RENDER_END_QUERY), + }), + occlusion_query_set: None, + multiview_mask: None, + }); + } + + encoder.write_timestamp(&query_set, SUBMIT_BEGIN_QUERY); + encoder.resolve_query_set(&query_set, 0..TIMESTAMP_QUERY_COUNT, &resolve_buffer, 0); + encoder.copy_buffer_to_buffer( + &resolve_buffer, + 0, + &readback_buffer, + 0, + TIMESTAMP_BUFFER_SIZE, + ); + encoder.write_timestamp(&query_set, SUBMIT_END_QUERY); + + queue.submit(std::iter::once(encoder.finish())); + + let slice = readback_buffer.slice(..); + let (tx, rx) = mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |result| { + let _ = tx.send(result); + }); + device + .poll(wgpu::PollType::wait_indefinitely()) + .map_err(|e| format!("Probe poll failed: {e}"))?; + rx.recv() + .map_err(|e| format!("Probe map receive failed: {e}"))? + .map_err(|e| format!("Probe map failed: {e}"))?; + + let mapped = slice.get_mapped_range(); + let raw = bytemuck::cast_slice::(&mapped); + let mut raw_queries = [0; TIMESTAMP_QUERY_COUNT as usize]; + raw_queries.copy_from_slice(&raw[..TIMESTAMP_QUERY_COUNT as usize]); + drop(mapped); + readback_buffer.unmap(); + + Ok(GpuTimestampProbeReport { + supported: true, + raw_queries, + }) +} diff --git a/goud_engine/src/libs/graphics/frame_timing.rs b/goud_engine/src/libs/graphics/frame_timing.rs index 77b1d8ee..e6eaac26 100644 --- a/goud_engine/src/libs/graphics/frame_timing.rs +++ b/goud_engine/src/libs/graphics/frame_timing.rs @@ -19,6 +19,10 @@ pub struct FramePhaseTimings { pub uniform_upload_us: u64, /// GPU render pass recording time. pub render_pass_us: u64, + /// GPU timestamp duration for the shadow pre-pass. + pub gpu_shadow_us: u64, + /// GPU timestamp duration for the main render pass. + pub gpu_render_us: u64, /// GPU command submission time. pub gpu_submit_us: u64, /// GPU readback stall time. @@ -53,6 +57,8 @@ pub fn record_timing(field: &str, value: u64) { "surface_acquire" => t.surface_acquire_us = value, "uniform_upload" => t.uniform_upload_us = value, "render_pass" => t.render_pass_us = value, + "gpu_shadow" => t.gpu_shadow_us = value, + "gpu_render" => t.gpu_render_us = value, "gpu_submit" => t.gpu_submit_us = value, "readback_stall" => t.readback_stall_us = value, "surface_present" => t.surface_present_us = value, @@ -100,6 +106,8 @@ mod tests { record_timing("render3d_scene", 300); record_timing("uniform_upload", 400); record_timing("render_pass", 500); + record_timing("gpu_shadow", 550); + record_timing("gpu_render", 575); record_timing("gpu_submit", 600); record_timing("readback_stall", 700); record_timing("surface_present", 800); @@ -116,6 +124,8 @@ mod tests { assert_eq!(t.render3d_scene_us, 300); assert_eq!(t.uniform_upload_us, 400); assert_eq!(t.render_pass_us, 500); + assert_eq!(t.gpu_shadow_us, 550); + assert_eq!(t.gpu_render_us, 575); assert_eq!(t.gpu_submit_us, 600); assert_eq!(t.readback_stall_us, 700); assert_eq!(t.surface_present_us, 800); @@ -135,6 +145,8 @@ mod tests { assert_eq!(t.render3d_scene_us, 0); assert_eq!(t.begin_frame_us, 0); assert_eq!(t.end_frame_us, 0); + assert_eq!(t.gpu_shadow_us, 0); + assert_eq!(t.gpu_render_us, 0); } #[test] @@ -156,6 +168,8 @@ mod tests { assert_eq!(t.shadow_pass_us, 0); assert_eq!(t.shadow_build_us, 0); assert_eq!(t.render3d_scene_us, 0); + assert_eq!(t.gpu_shadow_us, 0); + assert_eq!(t.gpu_render_us, 0); assert_eq!(t.uniform_upload_us, 0); assert_eq!(t.render_pass_us, 0); assert_eq!(t.gpu_submit_us, 0); diff --git a/goud_engine/tests/spec.rs b/goud_engine/tests/spec.rs index ceeffd39..877806b4 100644 --- a/goud_engine/tests/spec.rs +++ b/goud_engine/tests/spec.rs @@ -1,3 +1,5 @@ +#[path = "spec/eng2_p0_01_gpu_timestamp_queries.rs"] +mod eng2_p0_01_gpu_timestamp_queries; #[path = "spec/eng2_p0_03_bench_suite_expansion.rs"] mod eng2_p0_03_bench_suite_expansion; #[path = "spec/eng2_p0_04_profiling_integration.rs"] diff --git a/goud_engine/tests/spec/eng2_p0_01_gpu_timestamp_queries.rs b/goud_engine/tests/spec/eng2_p0_01_gpu_timestamp_queries.rs new file mode 100644 index 00000000..6f637450 --- /dev/null +++ b/goud_engine/tests/spec/eng2_p0_01_gpu_timestamp_queries.rs @@ -0,0 +1,38 @@ +use goud_engine::libs::graphics::backend::wgpu_backend::probe_gpu_timestamp_queries; + +#[test] +fn eng2_p0_01_query_set_populates_and_resolves_on_wgpu_backend() { + let report = match probe_gpu_timestamp_queries() { + Ok(report) => report, + Err(err) => { + eprintln!("Skipping ENG2-P0-01 probe: {err}"); + return; + } + }; + + if !report.supported { + eprintln!("Skipping ENG2-P0-01 probe: adapter does not expose timestamp-query features"); + return; + } + + assert!( + report.raw_queries.iter().all(|query| *query > 0), + "expected every timestamp slot to be written, got {:?}", + report.raw_queries + ); + assert!( + report.raw_queries[1] > report.raw_queries[0], + "expected shadow timestamps to advance, got {:?}", + report.raw_queries + ); + assert!( + report.raw_queries[3] > report.raw_queries[2], + "expected render timestamps to advance, got {:?}", + report.raw_queries + ); + assert!( + report.raw_queries[5] > report.raw_queries[4], + "expected submit-tail timestamps to advance, got {:?}", + report.raw_queries + ); +} From adf2054438aef8954a23293cc2e43d0761fb6f19 Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh Date: Tue, 7 Jul 2026 18:23:36 -0600 Subject: [PATCH 2/4] fix(ci): harden gpu timestamp query path --- .../graphics/backend/wgpu_backend/frame.rs | 8 +- .../graphics/backend/wgpu_backend/sdl_init.rs | 1 + .../backend/wgpu_backend/shadow_pass.rs | 11 +- .../backend/wgpu_backend/switch_init.rs | 1 + .../backend/wgpu_backend/timestamps.rs | 155 ++++++++++++++---- .../backend/wgpu_backend/xbox_init.rs | 1 + .../spec/eng2_p0_01_gpu_timestamp_queries.rs | 7 + 7 files changed, 152 insertions(+), 32 deletions(-) diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/frame.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/frame.rs index 813f614d..b0d10560 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/frame.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/frame.rs @@ -142,6 +142,9 @@ impl FrameOps for WgpuBackend { // -- render_pass phase ---------------------------------------------------- let render_pass_start = std::time::Instant::now(); + if let Some(gpu_timestamps) = self.gpu_timestamps.as_ref() { + gpu_timestamps.write_render_begin(&mut encoder); + } { crate::libs::profiling::profile_scope!(WGPU_RENDER_PASS); @@ -167,7 +170,7 @@ impl FrameOps for WgpuBackend { timestamp_writes: self .gpu_timestamps .as_ref() - .map(|gpu_timestamps| gpu_timestamps.render_pass_writes()), + .and_then(|gpu_timestamps| gpu_timestamps.render_pass_writes()), occlusion_query_set: None, multiview_mask: None, }); @@ -249,6 +252,9 @@ impl FrameOps for WgpuBackend { } } } + if let Some(gpu_timestamps) = self.gpu_timestamps.as_ref() { + gpu_timestamps.write_render_end(&mut encoder); + } let render_pass_us = render_pass_start.elapsed().as_micros() as u64; frame_timing::record_phase("render_pass", render_pass_us); diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/sdl_init.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/sdl_init.rs index 52997892..e685703c 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/sdl_init.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/sdl_init.rs @@ -320,6 +320,7 @@ impl WgpuBackend { scratch_shadow_pipeline_keys: Vec::new(), scratch_shadow_offsets: Vec::new(), scratch_shadow_grown_shaders: rustc_hash::FxHashSet::default(), + gpu_timestamps: None, }) } } diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/shadow_pass.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/shadow_pass.rs index 79441cfb..d817dd44 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/shadow_pass.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/shadow_pass.rs @@ -287,6 +287,9 @@ impl WgpuBackend { /// `shadow_draw_commands` after execution. pub(super) fn execute_shadow_pass(&mut self, encoder: &mut wgpu::CommandEncoder) { if self.shadow_draw_commands.is_empty() || self.shadow_depth_view.is_none() { + if let Some(gpu_timestamps) = self.gpu_timestamps.as_ref() { + gpu_timestamps.write_empty_shadow_phase(encoder); + } return; } @@ -374,6 +377,9 @@ impl WgpuBackend { // SAFETY: shadow_depth_view is confirmed Some above. let shadow_view = self.shadow_depth_view.as_ref().unwrap(); + if let Some(gpu_timestamps) = self.gpu_timestamps.as_ref() { + gpu_timestamps.write_shadow_begin(encoder); + } { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("shadow-pass"), @@ -389,7 +395,7 @@ impl WgpuBackend { timestamp_writes: self .gpu_timestamps .as_ref() - .map(|gpu_timestamps| gpu_timestamps.shadow_pass_writes()), + .and_then(|gpu_timestamps| gpu_timestamps.shadow_pass_writes()), occlusion_query_set: None, multiview_mask: None, }); @@ -442,6 +448,9 @@ impl WgpuBackend { } } } + if let Some(gpu_timestamps) = self.gpu_timestamps.as_ref() { + gpu_timestamps.write_shadow_end(encoder); + } self.shadow_draw_commands.clear(); // Return scratch buffers so they are reused next frame. self.scratch_shadow_pipeline_keys = shadow_keys; diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/switch_init.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/switch_init.rs index 9dcf6bb8..3c33252e 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/switch_init.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/switch_init.rs @@ -320,6 +320,7 @@ impl WgpuBackend { scratch_shadow_pipeline_keys: Vec::new(), scratch_shadow_offsets: Vec::new(), scratch_shadow_grown_shaders: rustc_hash::FxHashSet::default(), + gpu_timestamps: None, }) } } diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/timestamps.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/timestamps.rs index 52a70756..95322360 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/timestamps.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/timestamps.rs @@ -1,4 +1,7 @@ -use std::sync::mpsc::{self, Receiver, TryRecvError}; +use std::{ + sync::mpsc::{self, Receiver, TryRecvError}, + time::{Duration, Instant}, +}; use crate::libs::graphics::frame_timing; @@ -11,6 +14,7 @@ const SUBMIT_BEGIN_QUERY: u32 = 4; const SUBMIT_END_QUERY: u32 = 5; const TIMESTAMP_BUFFER_SIZE: u64 = (TIMESTAMP_QUERY_COUNT as u64) * std::mem::size_of::() as u64; +const SUBMIT_MARKER_COPY_SIZE: u64 = std::mem::size_of::() as u64; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(super) struct GpuTimestampFrameTimings { @@ -19,6 +23,12 @@ pub(super) struct GpuTimestampFrameTimings { pub(super) submit_us: u64, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TimestampPassMode { + RenderPassWrites, + EncoderWrites, +} + #[derive(Debug)] struct PendingTimestampReadback { receiver: Receiver>, @@ -36,6 +46,7 @@ pub(super) struct GpuTimestampQueries { resolve_buffer: wgpu::Buffer, readback_slots: [TimestampReadbackSlot; 2], timestamp_period_ns: f32, + pass_mode: TimestampPassMode, latest: GpuTimestampFrameTimings, next_slot: usize, } @@ -51,14 +62,17 @@ pub struct GpuTimestampProbeReport { impl GpuTimestampQueries { pub(super) fn requested_features(adapter_features: wgpu::Features) -> wgpu::Features { - let required = wgpu::Features::TIMESTAMP_QUERY - | wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS - | wgpu::Features::TIMESTAMP_QUERY_INSIDE_PASSES; + let required_encoder = + wgpu::Features::TIMESTAMP_QUERY | wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS; + + if !adapter_features.contains(required_encoder) { + return wgpu::Features::empty(); + } - if adapter_features.contains(required) { - required + if adapter_features.contains(wgpu::Features::TIMESTAMP_QUERY_INSIDE_PASSES) { + required_encoder | wgpu::Features::TIMESTAMP_QUERY_INSIDE_PASSES } else { - wgpu::Features::empty() + required_encoder } } @@ -72,6 +86,12 @@ impl GpuTimestampQueries { return None; } + let pass_mode = if required.contains(wgpu::Features::TIMESTAMP_QUERY_INSIDE_PASSES) { + TimestampPassMode::RenderPassWrites + } else { + TimestampPassMode::EncoderWrites + }; + let query_set = device.create_query_set(&wgpu::QuerySetDescriptor { label: Some("goud-gpu-timestamps"), ty: wgpu::QueryType::Timestamp, @@ -101,24 +121,58 @@ impl GpuTimestampQueries { resolve_buffer, readback_slots, timestamp_period_ns: queue.get_timestamp_period(), + pass_mode, latest: GpuTimestampFrameTimings::default(), next_slot: 0, }) } - pub(super) fn shadow_pass_writes(&self) -> wgpu::RenderPassTimestampWrites<'_> { - wgpu::RenderPassTimestampWrites { - query_set: &self.query_set, - beginning_of_pass_write_index: Some(SHADOW_BEGIN_QUERY), - end_of_pass_write_index: Some(SHADOW_END_QUERY), + pub(super) fn shadow_pass_writes(&self) -> Option> { + (self.pass_mode == TimestampPassMode::RenderPassWrites).then_some( + wgpu::RenderPassTimestampWrites { + query_set: &self.query_set, + beginning_of_pass_write_index: Some(SHADOW_BEGIN_QUERY), + end_of_pass_write_index: Some(SHADOW_END_QUERY), + }, + ) + } + + pub(super) fn render_pass_writes(&self) -> Option> { + (self.pass_mode == TimestampPassMode::RenderPassWrites).then_some( + wgpu::RenderPassTimestampWrites { + query_set: &self.query_set, + beginning_of_pass_write_index: Some(RENDER_BEGIN_QUERY), + end_of_pass_write_index: Some(RENDER_END_QUERY), + }, + ) + } + + pub(super) fn write_shadow_begin(&self, encoder: &mut wgpu::CommandEncoder) { + if self.pass_mode == TimestampPassMode::EncoderWrites { + encoder.write_timestamp(&self.query_set, SHADOW_BEGIN_QUERY); + } + } + + pub(super) fn write_shadow_end(&self, encoder: &mut wgpu::CommandEncoder) { + if self.pass_mode == TimestampPassMode::EncoderWrites { + encoder.write_timestamp(&self.query_set, SHADOW_END_QUERY); + } + } + + pub(super) fn write_empty_shadow_phase(&self, encoder: &mut wgpu::CommandEncoder) { + encoder.write_timestamp(&self.query_set, SHADOW_BEGIN_QUERY); + encoder.write_timestamp(&self.query_set, SHADOW_END_QUERY); + } + + pub(super) fn write_render_begin(&self, encoder: &mut wgpu::CommandEncoder) { + if self.pass_mode == TimestampPassMode::EncoderWrites { + encoder.write_timestamp(&self.query_set, RENDER_BEGIN_QUERY); } } - pub(super) fn render_pass_writes(&self) -> wgpu::RenderPassTimestampWrites<'_> { - wgpu::RenderPassTimestampWrites { - query_set: &self.query_set, - beginning_of_pass_write_index: Some(RENDER_BEGIN_QUERY), - end_of_pass_write_index: Some(RENDER_END_QUERY), + pub(super) fn write_render_end(&self, encoder: &mut wgpu::CommandEncoder) { + if self.pass_mode == TimestampPassMode::EncoderWrites { + encoder.write_timestamp(&self.query_set, RENDER_END_QUERY); } } @@ -129,10 +183,18 @@ impl GpuTimestampQueries { let slot_index = self.next_available_slot()?; let slot = &self.readback_slots[slot_index]; - // wgpu timestamps can only measure work inside the GPU command stream, not the - // CPU-side queue.submit() call itself. We therefore treat gpu_submit as the - // submission tail: query resolution plus the copy into the MAP_READ buffer. + // wgpu timestamps can only measure GPU command-stream work, not the CPU-side + // queue.submit() call itself. This marker brackets the encoded submission tail + // before the query resolve/copy that makes the timestamps readable next frame. encoder.write_timestamp(&self.query_set, SUBMIT_BEGIN_QUERY); + encoder.copy_buffer_to_buffer( + &self.resolve_buffer, + 0, + &slot.buffer, + 0, + SUBMIT_MARKER_COPY_SIZE, + ); + encoder.write_timestamp(&self.query_set, SUBMIT_END_QUERY); encoder.resolve_query_set( &self.query_set, 0..TIMESTAMP_QUERY_COUNT, @@ -146,7 +208,6 @@ impl GpuTimestampQueries { 0, TIMESTAMP_BUFFER_SIZE, ); - encoder.write_timestamp(&self.query_set, SUBMIT_END_QUERY); self.next_slot = (slot_index + 1) % self.readback_slots.len(); Some(slot_index) @@ -290,6 +351,8 @@ async fn probe_gpu_timestamp_queries_async() -> Result Result Result Result Result Result Result Result { + result.map_err(|e| format!("Probe map failed: {e}"))?; + break; + } + Err(TryRecvError::Disconnected) => { + return Err("Probe map callback disconnected".to_string()); + } + Err(TryRecvError::Empty) if Instant::now() >= deadline => { + return Err("Timed out waiting for timestamp probe readback".to_string()); + } + Err(TryRecvError::Empty) => { + std::thread::sleep(Duration::from_millis(10)); + } + } + } let mapped = slice.get_mapped_range(); let raw = bytemuck::cast_slice::(&mapped); diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/xbox_init.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/xbox_init.rs index 1481912d..3ad65ae7 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/xbox_init.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/xbox_init.rs @@ -320,6 +320,7 @@ impl WgpuBackend { scratch_shadow_pipeline_keys: Vec::new(), scratch_shadow_offsets: Vec::new(), scratch_shadow_grown_shaders: rustc_hash::FxHashSet::default(), + gpu_timestamps: None, }) } } diff --git a/goud_engine/tests/spec/eng2_p0_01_gpu_timestamp_queries.rs b/goud_engine/tests/spec/eng2_p0_01_gpu_timestamp_queries.rs index 6f637450..8efe1ff3 100644 --- a/goud_engine/tests/spec/eng2_p0_01_gpu_timestamp_queries.rs +++ b/goud_engine/tests/spec/eng2_p0_01_gpu_timestamp_queries.rs @@ -14,6 +14,13 @@ fn eng2_p0_01_query_set_populates_and_resolves_on_wgpu_backend() { eprintln!("Skipping ENG2-P0-01 probe: adapter does not expose timestamp-query features"); return; } + if report.raw_queries.iter().all(|query| *query == 0) { + eprintln!( + "Skipping ENG2-P0-01 probe: adapter returned zeroed timestamp-query data: {:?}", + report.raw_queries + ); + return; + } assert!( report.raw_queries.iter().all(|query| *query > 0), From d8399252774f83bf210d02e01f090e48977bd57e Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh Date: Tue, 7 Jul 2026 18:27:23 -0600 Subject: [PATCH 3/4] refactor(wgpu): split timestamp probe helper --- .../libs/graphics/backend/wgpu_backend/mod.rs | 3 +- .../backend/wgpu_backend/timestamp_probe.rs | 261 ++++++++++++++++++ .../backend/wgpu_backend/timestamps.rs | 258 +---------------- 3 files changed, 273 insertions(+), 249 deletions(-) create mode 100644 goud_engine/src/libs/graphics/backend/wgpu_backend/timestamp_probe.rs diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/mod.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/mod.rs index 387b1389..d91c78f1 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/mod.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/mod.rs @@ -45,6 +45,7 @@ mod switch_init; #[cfg(feature = "switch-vulkan")] pub(crate) mod switch_surface; mod texture; +mod timestamp_probe; mod timestamps; mod uniforms; #[cfg(feature = "xbox-gdk")] @@ -58,7 +59,7 @@ use resources::{ }; pub use init::{MAX_TEXTURE_UNITS, UNIFORM_BUFFER_SIZE}; -pub use timestamps::{probe_gpu_timestamp_queries, GpuTimestampProbeReport}; +pub use timestamp_probe::{probe_gpu_timestamp_queries, GpuTimestampProbeReport}; /// Whether to request the wgpu software fallback adapter. /// diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/timestamp_probe.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/timestamp_probe.rs new file mode 100644 index 00000000..9d09746d --- /dev/null +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/timestamp_probe.rs @@ -0,0 +1,261 @@ +use std::{ + sync::mpsc::{self, TryRecvError}, + time::{Duration, Instant}, +}; + +use super::{ + force_fallback_adapter, + timestamps::{ + GpuTimestampQueries, RENDER_BEGIN_QUERY, RENDER_END_QUERY, SHADOW_BEGIN_QUERY, + SHADOW_END_QUERY, SUBMIT_BEGIN_QUERY, SUBMIT_END_QUERY, SUBMIT_MARKER_COPY_SIZE, + TIMESTAMP_BUFFER_SIZE, TIMESTAMP_QUERY_COUNT, + }, +}; + +/// Result of the headless timestamp-query probe used by the ENG2 spec test. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GpuTimestampProbeReport { + /// Whether the selected adapter exposes the timestamp-query feature set. + pub supported: bool, + /// Raw timestamp slots resolved from the GPU query set. + pub raw_queries: [u64; TIMESTAMP_QUERY_COUNT as usize], +} + +/// Runs a headless wgpu timestamp-query pass pair and resolves the raw query slots. +pub fn probe_gpu_timestamp_queries() -> Result { + pollster::block_on(probe_gpu_timestamp_queries_async()) +} + +async fn probe_gpu_timestamp_queries_async() -> Result { + let instance = wgpu::Instance::default(); + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::LowPower, + compatible_surface: None, + force_fallback_adapter: force_fallback_adapter(), + }) + .await + .map_err(|e| format!("No suitable headless adapter: {e}"))?; + + let features = GpuTimestampQueries::requested_features(adapter.features()); + if features.is_empty() { + return Ok(GpuTimestampProbeReport { + supported: false, + raw_queries: [0; TIMESTAMP_QUERY_COUNT as usize], + }); + } + + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("goud-gpu-timestamp-probe"), + required_features: features, + required_limits: wgpu::Limits::default(), + ..Default::default() + }) + .await + .map_err(|e| format!("Failed to create probe device: {e}"))?; + + let use_render_pass_writes = features.contains(wgpu::Features::TIMESTAMP_QUERY_INSIDE_PASSES); + let query_set = device.create_query_set(&wgpu::QuerySetDescriptor { + label: Some("goud-gpu-timestamp-probe"), + ty: wgpu::QueryType::Timestamp, + count: TIMESTAMP_QUERY_COUNT, + }); + let resolve_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("goud-gpu-timestamp-probe-resolve"), + size: TIMESTAMP_BUFFER_SIZE, + usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + let readback_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("goud-gpu-timestamp-probe-readback"), + size: TIMESTAMP_BUFFER_SIZE, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let shadow_texture = create_probe_texture( + &device, + "goud-gpu-timestamp-probe-shadow", + wgpu::TextureFormat::Depth32Float, + ); + let color_texture = create_probe_texture( + &device, + "goud-gpu-timestamp-probe-color", + wgpu::TextureFormat::Rgba8Unorm, + ); + let depth_texture = create_probe_texture( + &device, + "goud-gpu-timestamp-probe-depth", + wgpu::TextureFormat::Depth32Float, + ); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("goud-gpu-timestamp-probe"), + }); + record_probe_passes( + &mut encoder, + &query_set, + use_render_pass_writes, + &shadow_texture.create_view(&wgpu::TextureViewDescriptor::default()), + &color_texture.create_view(&wgpu::TextureViewDescriptor::default()), + &depth_texture.create_view(&wgpu::TextureViewDescriptor::default()), + ); + record_probe_resolve(&mut encoder, &query_set, &resolve_buffer, &readback_buffer); + queue.submit(std::iter::once(encoder.finish())); + + read_probe_queries(&device, &readback_buffer) +} + +fn create_probe_texture( + device: &wgpu::Device, + label: &'static str, + format: wgpu::TextureFormat, +) -> wgpu::Texture { + device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }) +} + +fn record_probe_passes( + encoder: &mut wgpu::CommandEncoder, + query_set: &wgpu::QuerySet, + use_render_pass_writes: bool, + shadow_view: &wgpu::TextureView, + color_view: &wgpu::TextureView, + depth_view: &wgpu::TextureView, +) { + if !use_render_pass_writes { + encoder.write_timestamp(query_set, SHADOW_BEGIN_QUERY); + } + { + let _shadow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("goud-gpu-timestamp-probe-shadow"), + color_attachments: &[], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: shadow_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: use_render_pass_writes.then_some(wgpu::RenderPassTimestampWrites { + query_set, + beginning_of_pass_write_index: Some(SHADOW_BEGIN_QUERY), + end_of_pass_write_index: Some(SHADOW_END_QUERY), + }), + occlusion_query_set: None, + multiview_mask: None, + }); + } + if !use_render_pass_writes { + encoder.write_timestamp(query_set, SHADOW_END_QUERY); + encoder.write_timestamp(query_set, RENDER_BEGIN_QUERY); + } + { + let _render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("goud-gpu-timestamp-probe-render"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: color_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: use_render_pass_writes.then_some(wgpu::RenderPassTimestampWrites { + query_set, + beginning_of_pass_write_index: Some(RENDER_BEGIN_QUERY), + end_of_pass_write_index: Some(RENDER_END_QUERY), + }), + occlusion_query_set: None, + multiview_mask: None, + }); + } + if !use_render_pass_writes { + encoder.write_timestamp(query_set, RENDER_END_QUERY); + } +} + +fn record_probe_resolve( + encoder: &mut wgpu::CommandEncoder, + query_set: &wgpu::QuerySet, + resolve_buffer: &wgpu::Buffer, + readback_buffer: &wgpu::Buffer, +) { + encoder.write_timestamp(query_set, SUBMIT_BEGIN_QUERY); + encoder.copy_buffer_to_buffer( + resolve_buffer, + 0, + readback_buffer, + 0, + SUBMIT_MARKER_COPY_SIZE, + ); + encoder.write_timestamp(query_set, SUBMIT_END_QUERY); + encoder.resolve_query_set(query_set, 0..TIMESTAMP_QUERY_COUNT, resolve_buffer, 0); + encoder.copy_buffer_to_buffer(resolve_buffer, 0, readback_buffer, 0, TIMESTAMP_BUFFER_SIZE); +} + +fn read_probe_queries( + device: &wgpu::Device, + readback_buffer: &wgpu::Buffer, +) -> Result { + let slice = readback_buffer.slice(..); + let (tx, rx) = mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |result| { + let _ = tx.send(result); + }); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + device + .poll(wgpu::PollType::Poll) + .map_err(|e| format!("Probe poll failed: {e}"))?; + match rx.try_recv() { + Ok(result) => { + result.map_err(|e| format!("Probe map failed: {e}"))?; + break; + } + Err(TryRecvError::Disconnected) => { + return Err("Probe map callback disconnected".to_string()); + } + Err(TryRecvError::Empty) if Instant::now() >= deadline => { + return Err("Timed out waiting for timestamp probe readback".to_string()); + } + Err(TryRecvError::Empty) => { + std::thread::sleep(Duration::from_millis(10)); + } + } + } + + let mapped = slice.get_mapped_range(); + let raw = bytemuck::cast_slice::(&mapped); + let mut raw_queries = [0; TIMESTAMP_QUERY_COUNT as usize]; + raw_queries.copy_from_slice(&raw[..TIMESTAMP_QUERY_COUNT as usize]); + drop(mapped); + readback_buffer.unmap(); + + Ok(GpuTimestampProbeReport { + supported: true, + raw_queries, + }) +} diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/timestamps.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/timestamps.rs index 95322360..088082d8 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/timestamps.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/timestamps.rs @@ -1,20 +1,17 @@ -use std::{ - sync::mpsc::{self, Receiver, TryRecvError}, - time::{Duration, Instant}, -}; +use std::sync::mpsc::{self, Receiver, TryRecvError}; use crate::libs::graphics::frame_timing; -const TIMESTAMP_QUERY_COUNT: u32 = 6; -const SHADOW_BEGIN_QUERY: u32 = 0; -const SHADOW_END_QUERY: u32 = 1; -const RENDER_BEGIN_QUERY: u32 = 2; -const RENDER_END_QUERY: u32 = 3; -const SUBMIT_BEGIN_QUERY: u32 = 4; -const SUBMIT_END_QUERY: u32 = 5; -const TIMESTAMP_BUFFER_SIZE: u64 = +pub(super) const TIMESTAMP_QUERY_COUNT: u32 = 6; +pub(super) const SHADOW_BEGIN_QUERY: u32 = 0; +pub(super) const SHADOW_END_QUERY: u32 = 1; +pub(super) const RENDER_BEGIN_QUERY: u32 = 2; +pub(super) const RENDER_END_QUERY: u32 = 3; +pub(super) const SUBMIT_BEGIN_QUERY: u32 = 4; +pub(super) const SUBMIT_END_QUERY: u32 = 5; +pub(super) const TIMESTAMP_BUFFER_SIZE: u64 = (TIMESTAMP_QUERY_COUNT as u64) * std::mem::size_of::() as u64; -const SUBMIT_MARKER_COPY_SIZE: u64 = std::mem::size_of::() as u64; +pub(super) const SUBMIT_MARKER_COPY_SIZE: u64 = std::mem::size_of::() as u64; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(super) struct GpuTimestampFrameTimings { @@ -51,15 +48,6 @@ pub(super) struct GpuTimestampQueries { next_slot: usize, } -/// Result of the headless timestamp-query probe used by the ENG2 spec test. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct GpuTimestampProbeReport { - /// Whether the selected adapter exposes the timestamp-query feature set. - pub supported: bool, - /// Raw timestamp slots resolved from the GPU query set. - pub raw_queries: [u64; TIMESTAMP_QUERY_COUNT as usize], -} - impl GpuTimestampQueries { pub(super) fn requested_features(adapter_features: wgpu::Features) -> wgpu::Features { let required_encoder = @@ -316,229 +304,3 @@ fn raw_timestamp_delta_us(start: u64, end: u64, timestamp_period_ns: f32) -> u64 let delta_ticks = end - start; ((delta_ticks as f64) * (timestamp_period_ns as f64) / 1_000.0) as u64 } - -/// Runs a headless wgpu timestamp-query pass pair and resolves the raw query slots. -pub fn probe_gpu_timestamp_queries() -> Result { - pollster::block_on(probe_gpu_timestamp_queries_async()) -} - -async fn probe_gpu_timestamp_queries_async() -> Result { - let instance = wgpu::Instance::default(); - let adapter = instance - .request_adapter(&wgpu::RequestAdapterOptions { - power_preference: wgpu::PowerPreference::LowPower, - compatible_surface: None, - force_fallback_adapter: super::force_fallback_adapter(), - }) - .await - .map_err(|e| format!("No suitable headless adapter: {e}"))?; - - let features = GpuTimestampQueries::requested_features(adapter.features()); - if features.is_empty() { - return Ok(GpuTimestampProbeReport { - supported: false, - raw_queries: [0; TIMESTAMP_QUERY_COUNT as usize], - }); - } - - let (device, queue) = adapter - .request_device(&wgpu::DeviceDescriptor { - label: Some("goud-gpu-timestamp-probe"), - required_features: features, - required_limits: wgpu::Limits::default(), - ..Default::default() - }) - .await - .map_err(|e| format!("Failed to create probe device: {e}"))?; - - let use_render_pass_writes = features.contains(wgpu::Features::TIMESTAMP_QUERY_INSIDE_PASSES); - - let query_set = device.create_query_set(&wgpu::QuerySetDescriptor { - label: Some("goud-gpu-timestamp-probe"), - ty: wgpu::QueryType::Timestamp, - count: TIMESTAMP_QUERY_COUNT, - }); - let resolve_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("goud-gpu-timestamp-probe-resolve"), - size: TIMESTAMP_BUFFER_SIZE, - usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC, - mapped_at_creation: false, - }); - let readback_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("goud-gpu-timestamp-probe-readback"), - size: TIMESTAMP_BUFFER_SIZE, - usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, - mapped_at_creation: false, - }); - - let shadow_texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("goud-gpu-timestamp-probe-shadow"), - size: wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Depth32Float, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - view_formats: &[], - }); - let shadow_view = shadow_texture.create_view(&wgpu::TextureViewDescriptor::default()); - - let color_texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("goud-gpu-timestamp-probe-color"), - size: wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - view_formats: &[], - }); - let color_view = color_texture.create_view(&wgpu::TextureViewDescriptor::default()); - - let depth_texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("goud-gpu-timestamp-probe-depth"), - size: wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Depth32Float, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - view_formats: &[], - }); - let depth_view = depth_texture.create_view(&wgpu::TextureViewDescriptor::default()); - - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("goud-gpu-timestamp-probe"), - }); - if !use_render_pass_writes { - encoder.write_timestamp(&query_set, SHADOW_BEGIN_QUERY); - } - { - let _shadow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("goud-gpu-timestamp-probe-shadow"), - color_attachments: &[], - depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { - view: &shadow_view, - depth_ops: Some(wgpu::Operations { - load: wgpu::LoadOp::Clear(1.0), - store: wgpu::StoreOp::Store, - }), - stencil_ops: None, - }), - timestamp_writes: use_render_pass_writes.then_some(wgpu::RenderPassTimestampWrites { - query_set: &query_set, - beginning_of_pass_write_index: Some(SHADOW_BEGIN_QUERY), - end_of_pass_write_index: Some(SHADOW_END_QUERY), - }), - occlusion_query_set: None, - multiview_mask: None, - }); - } - if !use_render_pass_writes { - encoder.write_timestamp(&query_set, SHADOW_END_QUERY); - encoder.write_timestamp(&query_set, RENDER_BEGIN_QUERY); - } - { - let _render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("goud-gpu-timestamp-probe-render"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &color_view, - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), - store: wgpu::StoreOp::Store, - }, - depth_slice: None, - })], - depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { - view: &depth_view, - depth_ops: Some(wgpu::Operations { - load: wgpu::LoadOp::Clear(1.0), - store: wgpu::StoreOp::Store, - }), - stencil_ops: None, - }), - timestamp_writes: use_render_pass_writes.then_some(wgpu::RenderPassTimestampWrites { - query_set: &query_set, - beginning_of_pass_write_index: Some(RENDER_BEGIN_QUERY), - end_of_pass_write_index: Some(RENDER_END_QUERY), - }), - occlusion_query_set: None, - multiview_mask: None, - }); - } - if !use_render_pass_writes { - encoder.write_timestamp(&query_set, RENDER_END_QUERY); - } - - encoder.write_timestamp(&query_set, SUBMIT_BEGIN_QUERY); - encoder.copy_buffer_to_buffer( - &resolve_buffer, - 0, - &readback_buffer, - 0, - SUBMIT_MARKER_COPY_SIZE, - ); - encoder.write_timestamp(&query_set, SUBMIT_END_QUERY); - encoder.resolve_query_set(&query_set, 0..TIMESTAMP_QUERY_COUNT, &resolve_buffer, 0); - encoder.copy_buffer_to_buffer( - &resolve_buffer, - 0, - &readback_buffer, - 0, - TIMESTAMP_BUFFER_SIZE, - ); - - queue.submit(std::iter::once(encoder.finish())); - - let slice = readback_buffer.slice(..); - let (tx, rx) = mpsc::channel(); - slice.map_async(wgpu::MapMode::Read, move |result| { - let _ = tx.send(result); - }); - let deadline = Instant::now() + Duration::from_secs(5); - loop { - device - .poll(wgpu::PollType::Poll) - .map_err(|e| format!("Probe poll failed: {e}"))?; - match rx.try_recv() { - Ok(result) => { - result.map_err(|e| format!("Probe map failed: {e}"))?; - break; - } - Err(TryRecvError::Disconnected) => { - return Err("Probe map callback disconnected".to_string()); - } - Err(TryRecvError::Empty) if Instant::now() >= deadline => { - return Err("Timed out waiting for timestamp probe readback".to_string()); - } - Err(TryRecvError::Empty) => { - std::thread::sleep(Duration::from_millis(10)); - } - } - } - - let mapped = slice.get_mapped_range(); - let raw = bytemuck::cast_slice::(&mapped); - let mut raw_queries = [0; TIMESTAMP_QUERY_COUNT as usize]; - raw_queries.copy_from_slice(&raw[..TIMESTAMP_QUERY_COUNT as usize]); - drop(mapped); - readback_buffer.unmap(); - - Ok(GpuTimestampProbeReport { - supported: true, - raw_queries, - }) -} From 280e3615a15a6352356c8f6bacefee26a1a78dba Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh Date: Wed, 8 Jul 2026 13:57:24 -0600 Subject: [PATCH 4/4] test: harden gpu timestamp probe --- .../backend/wgpu_backend/timestamp_probe.rs | 211 +++++++++++++++++- .../backend/wgpu_backend/timestamps.rs | 31 ++- .../spec/eng2_p0_01_gpu_timestamp_queries.rs | 51 ++++- 3 files changed, 273 insertions(+), 20 deletions(-) diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/timestamp_probe.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/timestamp_probe.rs index 9d09746d..fca57564 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/timestamp_probe.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/timestamp_probe.rs @@ -6,9 +6,9 @@ use std::{ use super::{ force_fallback_adapter, timestamps::{ - GpuTimestampQueries, RENDER_BEGIN_QUERY, RENDER_END_QUERY, SHADOW_BEGIN_QUERY, - SHADOW_END_QUERY, SUBMIT_BEGIN_QUERY, SUBMIT_END_QUERY, SUBMIT_MARKER_COPY_SIZE, - TIMESTAMP_BUFFER_SIZE, TIMESTAMP_QUERY_COUNT, + raw_timestamp_delta_us, GpuTimestampQueries, RENDER_BEGIN_QUERY, RENDER_END_QUERY, + SHADOW_BEGIN_QUERY, SHADOW_END_QUERY, SUBMIT_BEGIN_QUERY, SUBMIT_END_QUERY, + SUBMIT_MARKER_COPY_SIZE, TIMESTAMP_BUFFER_SIZE, TIMESTAMP_QUERY_COUNT, }, }; @@ -17,8 +17,18 @@ use super::{ pub struct GpuTimestampProbeReport { /// Whether the selected adapter exposes the timestamp-query feature set. pub supported: bool, + /// Adapter name used for the probe. + pub adapter_name: String, + /// wgpu backend used for the probe. + pub backend: String, /// Raw timestamp slots resolved from the GPU query set. pub raw_queries: [u64; TIMESTAMP_QUERY_COUNT as usize], + /// Resolved shadow-pass GPU timestamp duration. + pub shadow_us: u64, + /// Resolved main render-pass GPU timestamp duration. + pub render_us: u64, + /// Resolved submit-tail GPU timestamp duration. + pub submit_us: u64, } /// Runs a headless wgpu timestamp-query pass pair and resolves the raw query slots. @@ -30,18 +40,26 @@ async fn probe_gpu_timestamp_queries_async() -> Result Result Result Result wgpu::ShaderModule { + device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("goud-gpu-timestamp-probe-shader"), + source: wgpu::ShaderSource::Wgsl( + r#" +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> @builtin(position) vec4 { + let x = f32(i32(vertex_index) - 1); + let y = select(-1.0, 1.0, vertex_index == 1u); + return vec4(x, y, 0.0, 1.0); +} + +@fragment +fn fs_main() -> @location(0) vec4 { + return vec4(1.0, 0.25, 0.0, 1.0); +} +"# + .into(), + ), + }) +} + +fn create_probe_pipeline( + device: &wgpu::Device, + layout: &wgpu::PipelineLayout, + shader: &wgpu::ShaderModule, + label: &'static str, + color_format: Option, +) -> wgpu::RenderPipeline { + let color_targets = color_format.map(|format| { + [Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })] + }); + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(layout), + vertex: wgpu::VertexState { + module: shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + primitive: wgpu::PrimitiveState::default(), + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::Less), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + fragment: color_targets.as_ref().map(|targets| wgpu::FragmentState { + module: shader, + entry_point: Some("fs_main"), + targets, + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + multiview_mask: None, + cache: None, + }) +} + fn record_probe_passes( encoder: &mut wgpu::CommandEncoder, query_set: &wgpu::QuerySet, @@ -134,12 +258,14 @@ fn record_probe_passes( shadow_view: &wgpu::TextureView, color_view: &wgpu::TextureView, depth_view: &wgpu::TextureView, + shadow_pipeline: &wgpu::RenderPipeline, + render_pipeline: &wgpu::RenderPipeline, ) { if !use_render_pass_writes { encoder.write_timestamp(query_set, SHADOW_BEGIN_QUERY); } { - let _shadow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + let mut shadow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("goud-gpu-timestamp-probe-shadow"), color_attachments: &[], depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { @@ -158,13 +284,15 @@ fn record_probe_passes( occlusion_query_set: None, multiview_mask: None, }); + shadow_pass.set_pipeline(shadow_pipeline); + shadow_pass.draw(0..3, 0..1); } if !use_render_pass_writes { encoder.write_timestamp(query_set, SHADOW_END_QUERY); encoder.write_timestamp(query_set, RENDER_BEGIN_QUERY); } { - let _render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("goud-gpu-timestamp-probe-render"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: color_view, @@ -191,34 +319,77 @@ fn record_probe_passes( occlusion_query_set: None, multiview_mask: None, }); + render_pass.set_pipeline(render_pipeline); + render_pass.draw(0..3, 0..1); } if !use_render_pass_writes { encoder.write_timestamp(query_set, RENDER_END_QUERY); } } -fn record_probe_resolve( +fn record_probe_submit_tail( encoder: &mut wgpu::CommandEncoder, query_set: &wgpu::QuerySet, - resolve_buffer: &wgpu::Buffer, + submit_marker_buffer: &wgpu::Buffer, readback_buffer: &wgpu::Buffer, ) { encoder.write_timestamp(query_set, SUBMIT_BEGIN_QUERY); encoder.copy_buffer_to_buffer( - resolve_buffer, + submit_marker_buffer, 0, readback_buffer, 0, SUBMIT_MARKER_COPY_SIZE, ); encoder.write_timestamp(query_set, SUBMIT_END_QUERY); +} + +fn record_probe_readback( + encoder: &mut wgpu::CommandEncoder, + query_set: &wgpu::QuerySet, + resolve_buffer: &wgpu::Buffer, + readback_buffer: &wgpu::Buffer, +) { encoder.resolve_query_set(query_set, 0..TIMESTAMP_QUERY_COUNT, resolve_buffer, 0); encoder.copy_buffer_to_buffer(resolve_buffer, 0, readback_buffer, 0, TIMESTAMP_BUFFER_SIZE); } +fn wait_for_submitted_work( + device: &wgpu::Device, + queue: &wgpu::Queue, + label: &'static str, +) -> Result<(), String> { + let (tx, rx) = mpsc::channel(); + queue.on_submitted_work_done(move || { + let _ = tx.send(()); + }); + + let deadline = Instant::now() + Duration::from_secs(5); + loop { + device + .poll(wgpu::PollType::Poll) + .map_err(|e| format!("Probe {label} poll failed: {e}"))?; + match rx.try_recv() { + Ok(()) => return Ok(()), + Err(TryRecvError::Disconnected) => { + return Err(format!("Probe {label} callback disconnected")); + } + Err(TryRecvError::Empty) if Instant::now() >= deadline => { + return Err(format!("Timed out waiting for timestamp probe {label}")); + } + Err(TryRecvError::Empty) => { + std::thread::sleep(Duration::from_millis(10)); + } + } + } +} + fn read_probe_queries( device: &wgpu::Device, + queue: &wgpu::Queue, readback_buffer: &wgpu::Buffer, + adapter_name: String, + backend: String, ) -> Result { let slice = readback_buffer.slice(..); let (tx, rx) = mpsc::channel(); @@ -254,8 +425,26 @@ fn read_probe_queries( drop(mapped); readback_buffer.unmap(); + let timestamp_period_ns = queue.get_timestamp_period(); Ok(GpuTimestampProbeReport { supported: true, + adapter_name, + backend, + shadow_us: raw_timestamp_delta_us( + raw_queries[SHADOW_BEGIN_QUERY as usize], + raw_queries[SHADOW_END_QUERY as usize], + timestamp_period_ns, + ), + render_us: raw_timestamp_delta_us( + raw_queries[RENDER_BEGIN_QUERY as usize], + raw_queries[RENDER_END_QUERY as usize], + timestamp_period_ns, + ), + submit_us: raw_timestamp_delta_us( + raw_queries[SUBMIT_BEGIN_QUERY as usize], + raw_queries[SUBMIT_END_QUERY as usize], + timestamp_period_ns, + ), raw_queries, }) } diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/timestamps.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/timestamps.rs index 088082d8..9f62d6f3 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/timestamps.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/timestamps.rs @@ -41,6 +41,7 @@ struct TimestampReadbackSlot { pub(super) struct GpuTimestampQueries { query_set: wgpu::QuerySet, resolve_buffer: wgpu::Buffer, + submit_marker_buffer: wgpu::Buffer, readback_slots: [TimestampReadbackSlot; 2], timestamp_period_ns: f32, pass_mode: TimestampPassMode, @@ -91,6 +92,12 @@ impl GpuTimestampQueries { usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false, }); + let submit_marker_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("goud-gpu-timestamp-submit-marker"), + size: SUBMIT_MARKER_COPY_SIZE, + usage: wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); let readback_slots = std::array::from_fn(|index| TimestampReadbackSlot { buffer: device.create_buffer(&wgpu::BufferDescriptor { label: Some(match index { @@ -107,6 +114,7 @@ impl GpuTimestampQueries { Some(Self { query_set, resolve_buffer, + submit_marker_buffer, readback_slots, timestamp_period_ns: queue.get_timestamp_period(), pass_mode, @@ -176,7 +184,7 @@ impl GpuTimestampQueries { // before the query resolve/copy that makes the timestamps readable next frame. encoder.write_timestamp(&self.query_set, SUBMIT_BEGIN_QUERY); encoder.copy_buffer_to_buffer( - &self.resolve_buffer, + &self.submit_marker_buffer, 0, &slot.buffer, 0, @@ -296,11 +304,28 @@ fn read_timestamp_timings( Some(timings) } -fn raw_timestamp_delta_us(start: u64, end: u64, timestamp_period_ns: f32) -> u64 { +pub(super) fn raw_timestamp_delta_us(start: u64, end: u64, timestamp_period_ns: f32) -> u64 { if end <= start { return 0; } let delta_ticks = end - start; - ((delta_ticks as f64) * (timestamp_period_ns as f64) / 1_000.0) as u64 + let elapsed_us = ((delta_ticks as f64) * (timestamp_period_ns as f64) / 1_000.0).ceil(); + elapsed_us.max(1.0) as u64 +} + +#[cfg(test)] +mod tests { + use super::raw_timestamp_delta_us; + + #[test] + fn timestamp_delta_rounds_up_sub_microsecond_work() { + assert_eq!(raw_timestamp_delta_us(10, 11, 1.0), 1); + } + + #[test] + fn timestamp_delta_keeps_zero_for_missing_or_reversed_queries() { + assert_eq!(raw_timestamp_delta_us(10, 10, 1.0), 0); + assert_eq!(raw_timestamp_delta_us(11, 10, 1.0), 0); + } } diff --git a/goud_engine/tests/spec/eng2_p0_01_gpu_timestamp_queries.rs b/goud_engine/tests/spec/eng2_p0_01_gpu_timestamp_queries.rs index 8efe1ff3..945064d2 100644 --- a/goud_engine/tests/spec/eng2_p0_01_gpu_timestamp_queries.rs +++ b/goud_engine/tests/spec/eng2_p0_01_gpu_timestamp_queries.rs @@ -1,4 +1,8 @@ -use goud_engine::libs::graphics::backend::wgpu_backend::probe_gpu_timestamp_queries; +use std::process::Command; + +use goud_engine::libs::graphics::backend::wgpu_backend::{ + probe_gpu_timestamp_queries, GpuTimestampProbeReport, +}; #[test] fn eng2_p0_01_query_set_populates_and_resolves_on_wgpu_backend() { @@ -14,18 +18,18 @@ fn eng2_p0_01_query_set_populates_and_resolves_on_wgpu_backend() { eprintln!("Skipping ENG2-P0-01 probe: adapter does not expose timestamp-query features"); return; } - if report.raw_queries.iter().all(|query| *query == 0) { + if has_macos_26_metal_zero_timestamp_bug(&report) { eprintln!( - "Skipping ENG2-P0-01 probe: adapter returned zeroed timestamp-query data: {:?}", - report.raw_queries + "Skipping ENG2-P0-01 probe: {} on {} returned zero timestamp queries; \ + wgpu #9414 tracks the macOS 26 Metal 4 counter-sample regression", + report.adapter_name, report.backend ); return; } - assert!( report.raw_queries.iter().all(|query| *query > 0), "expected every timestamp slot to be written, got {:?}", - report.raw_queries + report ); assert!( report.raw_queries[1] > report.raw_queries[0], @@ -42,4 +46,39 @@ fn eng2_p0_01_query_set_populates_and_resolves_on_wgpu_backend() { "expected submit-tail timestamps to advance, got {:?}", report.raw_queries ); + assert!( + report.shadow_us > 0, + "expected non-zero gpu_shadow duration, got {:?}", + report + ); + assert!( + report.render_us > 0, + "expected non-zero gpu_render duration, got {:?}", + report + ); + assert!( + report.submit_us > 0, + "expected non-zero gpu_submit duration, got {:?}", + report + ); +} + +fn has_macos_26_metal_zero_timestamp_bug(report: &GpuTimestampProbeReport) -> bool { + cfg!(target_os = "macos") + && report.backend == "metal" + && report.raw_queries.iter().all(|query| *query == 0) + && macos_major_version().is_some_and(|major| major >= 26) +} + +fn macos_major_version() -> Option { + let output = Command::new("sw_vers") + .arg("-productVersion") + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let version = String::from_utf8(output.stdout).ok()?; + version.trim().split('.').next()?.parse().ok() }