use std::sync::Arc;
use std::sync::atomic::{AtomicU8, AtomicU32, Ordering};
use std::time::Duration;
pub const RING_FRAMES: usize = 4;
pub const MAX_SPANS: usize = 8;
pub const DEFAULT_PASS_CAPACITY: usize = 24;
const MAX_PASS_CAPACITY: usize = (wgpu::QUERY_SET_MAX_QUERIES / 2) as usize;
const QUERY_BYTES: u64 = wgpu::QUERY_SIZE as u64;
const IMPLAUSIBLE_SPAN_NANOS: u64 = 1_000_000_000;
mod map_state {
pub const PENDING: u8 = 0;
pub const READY: u8 = 1;
pub const FAILED: u8 = 2;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GpuFrameSpans {
durations: [Duration; MAX_SPANS],
len: usize,
}
impl Default for GpuFrameSpans {
fn default() -> Self {
Self {
durations: [Duration::ZERO; MAX_SPANS],
len: 0,
}
}
}
impl GpuFrameSpans {
#[must_use]
pub fn zeroed(len: usize) -> Self {
Self {
durations: [Duration::ZERO; MAX_SPANS],
len: len.min(MAX_SPANS),
}
}
#[must_use]
pub fn len(&self) -> usize {
self.len
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len == 0
}
#[must_use]
pub fn span(&self, index: usize) -> Duration {
self.durations.get(index).copied().unwrap_or(Duration::ZERO)
}
#[must_use]
pub fn as_slice(&self) -> &[Duration] {
&self.durations[..self.len]
}
#[must_use]
pub fn total(&self) -> Duration {
self.as_slice()
.iter()
.copied()
.fold(Duration::ZERO, |acc, span| acc.saturating_add(span))
}
fn add(&mut self, span: usize, duration: Duration) {
if let Some(slot) = self.durations.get_mut(span) {
*slot = slot.saturating_add(duration);
}
}
}
#[derive(Debug)]
struct RingFrame {
queries: wgpu::QuerySet,
resolve: wgpu::Buffer,
readback: wgpu::Buffer,
cursor: AtomicU32,
owners: Vec<AtomicU8>,
state: SlotState,
}
#[derive(Debug)]
enum SlotState {
Idle,
Recording,
Mapping { used: usize, status: Arc<AtomicU8> },
}
#[derive(Debug)]
pub struct TimestampRing {
spans: usize,
passes: usize,
period_ns: f32,
frames: Vec<RingFrame>,
current: usize,
armed: bool,
latest: Option<GpuFrameSpans>,
harvested: u64,
}
impl TimestampRing {
#[must_use]
pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, spans: usize, label: &str) -> Self {
Self::with_capacity(device, queue, spans, DEFAULT_PASS_CAPACITY, label)
}
#[must_use]
pub fn with_capacity(
device: &wgpu::Device,
queue: &wgpu::Queue,
spans: usize,
passes: usize,
label: &str,
) -> Self {
let spans = spans.min(MAX_SPANS);
let passes = passes.min(MAX_PASS_CAPACITY);
if spans == 0 || passes == 0 || !device.features().contains(wgpu::Features::TIMESTAMP_QUERY)
{
return Self::inert(spans);
}
let period_ns = queue.get_timestamp_period();
if !period_ns.is_finite() || period_ns <= 0.0 {
log::warn!(
"frust-gpu: timestamp period {period_ns} is unusable; GPU pass timing is off"
);
return Self::inert(spans);
}
let query_count = (passes * 2) as u32;
let staged_bytes = u64::from(query_count) * QUERY_BYTES;
let buffer_bytes = staged_bytes.next_multiple_of(wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT);
let frames = (0..RING_FRAMES)
.map(|slot| RingFrame {
queries: device.create_query_set(&wgpu::QuerySetDescriptor {
label: Some(&format!("{label} queries {slot}")),
ty: wgpu::QueryType::Timestamp,
count: query_count,
}),
resolve: device.create_buffer(&wgpu::BufferDescriptor {
label: Some(&format!("{label} resolve {slot}")),
size: buffer_bytes,
usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
}),
readback: device.create_buffer(&wgpu::BufferDescriptor {
label: Some(&format!("{label} readback {slot}")),
size: buffer_bytes,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
}),
cursor: AtomicU32::new(0),
owners: (0..passes).map(|_| AtomicU8::new(0)).collect(),
state: SlotState::Idle,
})
.collect();
Self {
spans,
passes,
period_ns,
frames,
current: RING_FRAMES - 1,
armed: false,
latest: None,
harvested: 0,
}
}
#[must_use]
pub fn inert(spans: usize) -> Self {
Self {
spans: spans.min(MAX_SPANS),
passes: 0,
period_ns: 0.0,
frames: Vec::new(),
current: 0,
armed: false,
latest: None,
harvested: 0,
}
}
#[must_use]
pub fn is_active(&self) -> bool {
!self.frames.is_empty()
}
#[must_use]
pub fn spans(&self) -> usize {
self.spans
}
#[must_use]
pub fn timestamp_period_ns(&self) -> f32 {
self.period_ns
}
#[must_use]
pub fn latest(&self) -> Option<GpuFrameSpans> {
self.latest
}
#[must_use]
pub fn harvested(&self) -> u64 {
self.harvested
}
pub fn begin_frame(&mut self, device: &wgpu::Device) {
if self.frames.is_empty() {
return;
}
self.current = (self.current + 1) % self.frames.len();
let _ = device.poll(wgpu::PollType::Poll);
let spans = self.spans;
let period_ns = self.period_ns;
let Some(slot) = self.frames.get_mut(self.current) else {
self.armed = false;
return;
};
if let SlotState::Mapping { used, status } = &slot.state {
match status.load(Ordering::Acquire) {
map_state::PENDING => {
self.armed = false;
return;
}
map_state::READY => {
let reading = harvest(slot, *used, spans, period_ns);
slot.readback.unmap();
self.latest = Some(reading);
self.harvested = self.harvested.saturating_add(1);
}
_ => slot.readback.unmap(),
}
}
slot.cursor.store(0, Ordering::Relaxed);
slot.state = SlotState::Recording;
self.armed = true;
}
#[must_use]
pub fn pass_writes(&self, span: usize) -> Option<wgpu::RenderPassTimestampWrites<'_>> {
if !self.armed || span >= self.spans {
return None;
}
let slot = self.frames.get(self.current)?;
let pair = slot.cursor.fetch_add(1, Ordering::Relaxed) as usize;
if pair >= self.passes {
return None;
}
slot.owners.get(pair)?.store(span as u8, Ordering::Relaxed);
let first = (pair * 2) as u32;
Some(wgpu::RenderPassTimestampWrites {
query_set: &slot.queries,
beginning_of_pass_write_index: Some(first),
end_of_pass_write_index: Some(first + 1),
})
}
pub fn resolve(&self, encoder: &mut wgpu::CommandEncoder) {
if !self.armed {
return;
}
let Some(slot) = self.frames.get(self.current) else {
return;
};
let used = self.used_pairs(slot);
if used == 0 {
return;
}
let queries = (used * 2) as u32;
encoder.resolve_query_set(&slot.queries, 0..queries, &slot.resolve, 0);
encoder.copy_buffer_to_buffer(
&slot.resolve,
0,
&slot.readback,
0,
u64::from(queries) * QUERY_BYTES,
);
}
pub fn end_frame(&mut self) {
if !self.armed {
return;
}
self.armed = false;
let Some(slot) = self.frames.get_mut(self.current) else {
return;
};
let used = (slot.cursor.load(Ordering::Relaxed) as usize).min(self.passes);
if used == 0 {
slot.state = SlotState::Idle;
return;
}
let status = Arc::new(AtomicU8::new(map_state::PENDING));
let callback = Arc::clone(&status);
let bytes = (used * 2) as u64 * QUERY_BYTES;
slot.readback
.slice(0..bytes)
.map_async(wgpu::MapMode::Read, move |result| {
let state = if result.is_ok() {
map_state::READY
} else {
map_state::FAILED
};
callback.store(state, Ordering::Release);
});
slot.state = SlotState::Mapping { used, status };
}
pub fn abandon_frame(&mut self) {
if !self.armed {
return;
}
self.armed = false;
if let Some(slot) = self.frames.get_mut(self.current) {
slot.cursor.store(0, Ordering::Relaxed);
slot.state = SlotState::Idle;
}
}
fn used_pairs(&self, slot: &RingFrame) -> usize {
(slot.cursor.load(Ordering::Relaxed) as usize).min(self.passes)
}
}
fn harvest(slot: &RingFrame, used: usize, spans: usize, period_ns: f32) -> GpuFrameSpans {
let mut reading = GpuFrameSpans::zeroed(spans);
let bytes = (used * 2) as u64 * QUERY_BYTES;
let mapped =
slot.readback.slice(0..bytes).get_mapped_range().expect(
"frust-gpu diag: the harvested slot's readback is mapped over the resolved range",
);
let stride = (QUERY_BYTES * 2) as usize;
for pair in 0..used {
let at = pair * stride;
let Some(chunk) = mapped.get(at..at + stride) else {
break;
};
let (begin, end) = tick_pair(chunk);
let Some(duration) = span_duration(begin, end, period_ns) else {
continue;
};
let owner = slot
.owners
.get(pair)
.map_or(usize::MAX, |span| span.load(Ordering::Relaxed) as usize);
reading.add(owner, duration);
}
drop(mapped);
reading
}
fn tick_pair(chunk: &[u8]) -> (u64, u64) {
fn tick(bytes: Option<&[u8]>) -> u64 {
bytes
.and_then(|slice| <[u8; 8]>::try_from(slice).ok())
.map_or(0, u64::from_le_bytes)
}
(tick(chunk.get(0..8)), tick(chunk.get(8..16)))
}
fn span_duration(begin: u64, end: u64, period_ns: f32) -> Option<Duration> {
if end <= begin || !period_ns.is_finite() || period_ns <= 0.0 {
return None;
}
let nanos = (end - begin) as f64 * f64::from(period_ns);
if !nanos.is_finite() || nanos >= IMPLAUSIBLE_SPAN_NANOS as f64 {
return None;
}
Some(Duration::from_nanos(nanos as u64))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zeroed_reading_reports_its_span_count_and_no_time() {
let reading = GpuFrameSpans::zeroed(4);
assert_eq!(reading.len(), 4);
assert!(!reading.is_empty());
assert_eq!(reading.as_slice().len(), 4);
assert_eq!(reading.total(), Duration::ZERO);
for index in 0..4 {
assert_eq!(reading.span(index), Duration::ZERO);
}
}
#[test]
fn span_count_is_clamped_to_the_ceiling() {
assert_eq!(GpuFrameSpans::zeroed(MAX_SPANS + 9).len(), MAX_SPANS);
}
#[test]
fn an_index_past_the_end_reads_zero_rather_than_panicking() {
let reading = GpuFrameSpans::zeroed(2);
assert_eq!(reading.span(7), Duration::ZERO);
assert_eq!(reading.span(usize::MAX), Duration::ZERO);
}
#[test]
fn several_passes_charged_to_one_span_add_up() {
let mut reading = GpuFrameSpans::zeroed(3);
reading.add(1, Duration::from_micros(400));
reading.add(1, Duration::from_micros(250));
reading.add(2, Duration::from_micros(90));
assert_eq!(reading.span(0), Duration::ZERO);
assert_eq!(reading.span(1), Duration::from_micros(650));
assert_eq!(reading.span(2), Duration::from_micros(90));
assert_eq!(reading.total(), Duration::from_micros(740));
}
#[test]
fn a_charge_past_the_span_ceiling_is_dropped_not_wrapped() {
let mut reading = GpuFrameSpans::zeroed(2);
reading.add(MAX_SPANS, Duration::from_secs(1));
assert_eq!(reading.total(), Duration::ZERO);
}
#[test]
fn span_duration_scales_ticks_by_the_period() {
assert_eq!(span_duration(0, 1_000, 1.0), Some(Duration::from_micros(1)));
assert_eq!(
span_duration(500, 1_500, 38.4),
Some(Duration::from_nanos(38_400))
);
}
#[test]
fn a_non_increasing_tick_pair_is_no_measurement() {
assert_eq!(span_duration(0, 0, 1.0), None);
assert_eq!(span_duration(900, 900, 1.0), None);
assert_eq!(span_duration(1_000, 999, 1.0), None);
}
#[test]
fn an_implausible_span_is_dropped_rather_than_reported() {
assert_eq!(span_duration(0, IMPLAUSIBLE_SPAN_NANOS, 1.0), None);
assert_eq!(span_duration(0, u64::MAX, 1.0), None);
assert!(span_duration(0, IMPLAUSIBLE_SPAN_NANOS - 1, 1.0).is_some());
}
#[test]
fn an_unusable_period_yields_no_measurement() {
assert_eq!(span_duration(0, 1_000, 0.0), None);
assert_eq!(span_duration(0, 1_000, -1.0), None);
assert_eq!(span_duration(0, 1_000, f32::NAN), None);
assert_eq!(span_duration(0, 1_000, f32::INFINITY), None);
}
#[test]
fn a_tick_pair_reads_little_endian_and_a_short_chunk_reads_zero() {
let mut chunk = [0_u8; 16];
chunk[..8].copy_from_slice(&7_u64.to_le_bytes());
chunk[8..].copy_from_slice(&19_u64.to_le_bytes());
assert_eq!(tick_pair(&chunk), (7, 19));
assert_eq!(tick_pair(&chunk[..12]), (7, 0));
assert_eq!(tick_pair(&[]), (0, 0));
}
#[test]
fn an_inert_ring_measures_nothing_and_never_reports_a_reading() {
let mut ring = TimestampRing::inert(4);
assert!(!ring.is_active());
assert_eq!(ring.spans(), 4);
assert_eq!(ring.timestamp_period_ns(), 0.0);
assert!(ring.pass_writes(0).is_none());
assert!(ring.latest().is_none());
ring.end_frame();
ring.abandon_frame();
assert!(ring.latest().is_none());
assert_eq!(ring.harvested(), 0);
}
const RING_TEST_WGSL: &str = "\
@vertex
fn vs_main(@builtin(vertex_index) index: u32) -> @builtin(position) vec4<f32> {
let uv = vec2<f32>(f32((index << 1u) & 2u), f32(index & 2u));
return vec4<f32>(uv * 2.0 - 1.0, 0.0, 1.0);
}
@fragment
fn fs_main() -> @location(0) vec4<f32> {
return vec4<f32>(0.2, 0.4, 0.8, 1.0);
}
";
fn block_on<F: std::future::Future>(future: F) -> F::Output {
use std::task::{Context, Poll, Waker};
let waker = Waker::noop();
let mut cx = Context::from_waker(waker);
let mut future = std::pin::pin!(future);
loop {
match future.as_mut().poll(&mut cx) {
Poll::Ready(value) => return value,
Poll::Pending => std::thread::yield_now(),
}
}
}
#[test]
#[ignore = "needs a real GPU adapter offering TIMESTAMP_QUERY; run with \
`cargo test -p frust-gpu -- --ignored` (pin the adapter on a \
multi-GPU host with WGPU_BACKEND / WGPU_ADAPTER_NAME)"]
fn a_real_adapter_reports_plausible_per_pass_gpu_time() {
block_on(async {
let instance = wgpu::Instance::new(
wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
);
let adapter = wgpu::util::initialize_adapter_from_env_or_default(&instance, None)
.await
.expect("no compatible GPU adapter");
println!("frust-gpu timestamp-ring adapter: {:?}", adapter.get_info());
if !adapter.features().contains(wgpu::Features::TIMESTAMP_QUERY) {
println!("adapter offers no TIMESTAMP_QUERY — the inert path is the whole story");
return;
}
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some("frust-gpu timestamp ring test device"),
required_features: wgpu::Features::TIMESTAMP_QUERY,
required_limits: wgpu::Limits::default(),
..Default::default()
})
.await
.expect("failed to create the device");
let mut ring = TimestampRing::new(&device, &queue, 2, "frust-gpu ring test");
assert!(
ring.is_active(),
"a TIMESTAMP_QUERY device must arm the ring"
);
let format = wgpu::TextureFormat::Rgba8Unorm;
let target = crate::HeadlessTarget::new(&device, 512, 512, format);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("frust-gpu ring test shader"),
source: wgpu::ShaderSource::Wgsl(RING_TEST_WGSL.into()),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("frust-gpu ring test pipeline"),
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
targets: &[Some(format.into())],
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
let mut readings = 0_u64;
for frame in 0..(RING_FRAMES * 3) {
ring.begin_frame(&device);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("frust-gpu ring test frame"),
});
for (span, instances) in [(0_usize, 64_u32), (0, 64), (1, 64)] {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("frust-gpu ring test pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target.view(),
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: ring.pass_writes(span),
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_pipeline(&pipeline);
pass.draw(0..3, 0..instances);
}
ring.resolve(&mut encoder);
queue.submit([encoder.finish()]);
ring.end_frame();
let _ = device.poll(wgpu::PollType::wait_indefinitely());
if ring.harvested() > readings {
readings = ring.harvested();
println!("frame {frame}: {:?}", ring.latest());
}
}
let reading = ring
.latest()
.expect("a TIMESTAMP_QUERY device must have produced at least one reading");
assert_eq!(reading.len(), 2);
assert!(
reading.span(0) > Duration::ZERO,
"span 0 covered two real render passes: {reading:?}"
);
assert!(
reading.span(1) > Duration::ZERO,
"span 1 covered a real render pass: {reading:?}"
);
assert_eq!(reading.total(), reading.span(0) + reading.span(1));
assert!(
reading.total() < Duration::from_millis(100),
"three 256x256 clears cannot plausibly take {:?}",
reading.total()
);
});
}
}