Skip to main content

alpha2_render/
frame_gpu_time.rs

1//! GPU time of an export frame's graph and pack, sampled every eighth frame with timestamp
2//! queries.
3//!
4//! CPU timings show when work was recorded and submitted, not how long the GPU ran it. Three
5//! empty compute passes bracket the graph and the pack in the frame's encoder; their timestamps
6//! resolve into a ring of mappable buffers read on a later frame once mapped. Nothing waits on
7//! them, so sampling is effectively free.
8
9use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
10use std::sync::{Arc, Mutex};
11
12/// Every this many frames one is sampled.
13const SAMPLE_EVERY: u64 = 8;
14/// Ring size; a frame skips sampling when every slot's map is still pending.
15const RING: usize = 4;
16const RESOLVED_BYTES: u64 = 3 * 8;
17
18/// What the sampled frames of an export measured.
19#[derive(Clone, Copy, Debug, Default, PartialEq)]
20pub struct FrameGpuTimes {
21    /// Summed GPU time from graph start to graph end.
22    pub render_ms: f64,
23    /// Summed GPU time from graph end to pack end.
24    pub pack_ms: f64,
25    /// Frames whose timestamps were read.
26    pub sampled_frames: u64,
27    /// Frames whose three timestamps were equal, as on backends that do not advance the counter
28    /// across empty passes (Metal). Not counted in `sampled_frames`.
29    pub flat_frames: u64,
30}
31
32impl FrameGpuTimes {
33    /// The mean graph time of a sampled frame, when any was sampled.
34    #[must_use]
35    #[allow(clippy::cast_precision_loss, reason = "a frame count far below 2^52")]
36    pub fn render_ms_per_frame(&self) -> Option<f64> {
37        (self.sampled_frames > 0).then(|| self.render_ms / self.sampled_frames as f64)
38    }
39
40    /// The mean pack time of a sampled frame, when any was sampled.
41    #[must_use]
42    #[allow(clippy::cast_precision_loss, reason = "a frame count far below 2^52")]
43    pub fn pack_ms_per_frame(&self) -> Option<f64> {
44        (self.sampled_frames > 0).then(|| self.pack_ms / self.sampled_frames as f64)
45    }
46}
47
48#[derive(Debug)]
49struct Slot {
50    resolve: wgpu::Buffer,
51    readback: wgpu::Buffer,
52    pending: AtomicBool,
53    /// Map outcome set by the callback: [`MAP_WAITING`], [`MAP_DONE`] or [`MAP_FAILED`].
54    mapped: Arc<AtomicU8>,
55}
56
57const MAP_WAITING: u8 = 0;
58const MAP_DONE: u8 = 1;
59const MAP_FAILED: u8 = 2;
60
61#[derive(Debug)]
62pub(crate) struct FrameGpuTimer {
63    queries: wgpu::QuerySet,
64    slots: Vec<Slot>,
65    frames: AtomicU64,
66    /// Nanoseconds per timestamp tick.
67    period: f32,
68    totals: Mutex<FrameGpuTimes>,
69}
70
71pub(crate) struct FrameSample<'a> {
72    timer: &'a FrameGpuTimer,
73    slot: usize,
74}
75
76impl FrameGpuTimer {
77    /// Requires `TIMESTAMP_QUERY` on `device`.
78    pub(crate) fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
79        let queries = device.create_query_set(&wgpu::QuerySetDescriptor {
80            label: Some("alpha2 frame GPU time"),
81            ty: wgpu::QueryType::Timestamp,
82            count: 3,
83        });
84        let slots = (0..RING)
85            .map(|_| Slot {
86                resolve: device.create_buffer(&wgpu::BufferDescriptor {
87                    label: Some("alpha2 frame GPU time resolve"),
88                    size: RESOLVED_BYTES,
89                    usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
90                    mapped_at_creation: false,
91                }),
92                readback: device.create_buffer(&wgpu::BufferDescriptor {
93                    label: Some("alpha2 frame GPU time readback"),
94                    size: RESOLVED_BYTES,
95                    usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
96                    mapped_at_creation: false,
97                }),
98                pending: AtomicBool::new(false),
99                mapped: Arc::new(AtomicU8::new(MAP_WAITING)),
100            })
101            .collect();
102        Self {
103            queries,
104            slots,
105            frames: AtomicU64::new(0),
106            period: queue.get_timestamp_period(),
107            totals: Mutex::default(),
108        }
109    }
110
111    #[allow(
112        clippy::cast_precision_loss,
113        reason = "a frame's span in timestamp ticks, far below 2^52"
114    )]
115    fn collect(&self) {
116        let mut totals = self
117            .totals
118            .lock()
119            .unwrap_or_else(std::sync::PoisonError::into_inner);
120        for slot in &self.slots {
121            if !slot.pending.load(Ordering::Acquire) {
122                continue;
123            }
124            match slot.mapped.load(Ordering::Acquire) {
125                MAP_WAITING => continue,
126                // A failed map (device lost) frees the slot without a sample.
127                MAP_FAILED => {
128                    slot.mapped.store(MAP_WAITING, Ordering::Release);
129                    slot.pending.store(false, Ordering::Release);
130                    continue;
131                }
132                _ => {}
133            }
134            if let Ok(bytes) = slot.readback.slice(..).get_mapped_range() {
135                let tick = |at: usize| {
136                    let mut word = [0; 8];
137                    word.copy_from_slice(&bytes[at * 8..at * 8 + 8]);
138                    u64::from_le_bytes(word)
139                };
140                let (start, graph_end, pack_end) = (tick(0), tick(1), tick(2));
141                // A reset or wrapped counter gives out-of-order timestamps; drop the sample.
142                if start == pack_end {
143                    totals.flat_frames += 1;
144                } else if start <= graph_end && graph_end <= pack_end {
145                    let milliseconds =
146                        |ticks: u64| ticks as f64 * f64::from(self.period) / 1_000_000.0;
147                    totals.render_ms += milliseconds(graph_end - start);
148                    totals.pack_ms += milliseconds(pack_end - graph_end);
149                    totals.sampled_frames += 1;
150                }
151            }
152            slot.readback.unmap();
153            slot.mapped.store(MAP_WAITING, Ordering::Release);
154            slot.pending.store(false, Ordering::Release);
155        }
156    }
157
158    /// Returns a free slot for every eighth frame, or `None`.
159    pub(crate) fn begin(&self) -> Option<FrameSample<'_>> {
160        let frame = self.frames.fetch_add(1, Ordering::Relaxed);
161        if !frame.is_multiple_of(SAMPLE_EVERY) {
162            return None;
163        }
164        self.collect();
165        let slot = self
166            .slots
167            .iter()
168            .position(|slot| !slot.pending.load(Ordering::Acquire))?;
169        Some(FrameSample { timer: self, slot })
170    }
171
172    /// Totals of frames sampled since the last take, from completed maps; then resets them.
173    pub(crate) fn take(&self) -> FrameGpuTimes {
174        self.collect();
175        std::mem::take(
176            &mut *self
177                .totals
178                .lock()
179                .unwrap_or_else(std::sync::PoisonError::into_inner),
180        )
181    }
182}
183
184impl FrameSample<'_> {
185    /// An empty compute pass writing timestamp `index`: at its end for the first, at its beginning
186    /// for the others.
187    fn mark(&self, encoder: &mut wgpu::CommandEncoder, index: u32) {
188        let writes = if index == 0 {
189            wgpu::ComputePassTimestampWrites {
190                query_set: &self.timer.queries,
191                beginning_of_pass_write_index: None,
192                end_of_pass_write_index: Some(index),
193            }
194        } else {
195            wgpu::ComputePassTimestampWrites {
196                query_set: &self.timer.queries,
197                beginning_of_pass_write_index: Some(index),
198                end_of_pass_write_index: None,
199            }
200        };
201        drop(encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
202            label: Some("alpha2 frame GPU time mark"),
203            timestamp_writes: Some(writes),
204        }));
205    }
206
207    pub(crate) fn graph_start(&self, encoder: &mut wgpu::CommandEncoder) {
208        self.mark(encoder, 0);
209    }
210
211    pub(crate) fn graph_end(&self, encoder: &mut wgpu::CommandEncoder) {
212        self.mark(encoder, 1);
213    }
214
215    /// Marks the end of the pack and resolves the three timestamps toward the slot.
216    pub(crate) fn pack_end(&self, encoder: &mut wgpu::CommandEncoder) {
217        self.mark(encoder, 2);
218        let slot = &self.timer.slots[self.slot];
219        encoder.resolve_query_set(&self.timer.queries, 0..3, &slot.resolve, 0);
220        encoder.copy_buffer_to_buffer(&slot.resolve, 0, &slot.readback, 0, RESOLVED_BYTES);
221    }
222
223    /// Maps the slot after the frame's submission, for a later frame to read.
224    pub(crate) fn submitted(self, gpu: &alpha2_gpu::GpuContext) {
225        let slot = &self.timer.slots[self.slot];
226        slot.pending.store(true, Ordering::Release);
227        let mapped = Arc::clone(&slot.mapped);
228        gpu.map_async(
229            &slot.readback.slice(..),
230            wgpu::MapMode::Read,
231            move |result| {
232                mapped.store(
233                    if result.is_ok() { MAP_DONE } else { MAP_FAILED },
234                    Ordering::Release,
235                );
236            },
237        );
238    }
239}