Skip to main content

frust_engine/
diag.rs

1//! The engine's frame-diagnostics vocabulary: which GPU spans a frame is split
2//! into, and the label every GPU object it creates carries.
3//!
4//! # Spans
5//!
6//! [`EngineSpan`] names the four parts of an engine frame worth timing
7//! separately, and is the *only* place that naming lives — the substrate ring
8//! ([`frust_gpu::diag::TimestampRing`]) knows how many spans there are and
9//! which index each pass was charged to, never what any of them means. A host
10//! reads the frame's spans back out in this order and reports them as
11//! `gpu_prepass_us`/`gpu_main_us`/`gpu_composite_us`/`gpu_blit_us`.
12//!
13//! Several passes may be charged to one span, which is the ordinary case:
14//! [`EngineSpan::Main`] covers the clear, the opaque strip pass and every
15//! surface round's alpha strips, and [`EngineSpan::Composite`] covers every
16//! layer page round and every filter pass. Their durations add up.
17//!
18//! # Labels
19//!
20//! Every texture, buffer, pipeline and pass this crate creates is labelled
21//! `frust-engine <kind>`, so a GPU capture (Xcode, RenderDoc,
22//! `wgpu`'s own validation output) names the engine's own objects rather than
23//! showing an anonymous wall of resources next to the host's. [`LABEL_PREFIX`]
24//! is that prefix and [`labels_are_prefixed`] is the guard that keeps a new
25//! resource from slipping in unlabelled.
26//!
27//! The prefix is inert where a platform strips debug information — the Android
28//! emulator's instance flags drop `DEBUG` (see `frust_gpu::context`) — which
29//! costs nothing: the label is a static string either way.
30
31use frust_gpu::diag::TimestampRing;
32
33/// The prefix every GPU object this crate creates carries.
34pub const LABEL_PREFIX: &str = "frust-engine ";
35
36/// The label the engine's own timestamp ring names its query sets and staging
37/// buffers with.
38pub const TIMESTAMP_RING_LABEL: &str = "frust-engine gpu timestamps";
39
40/// One of the four GPU spans an engine frame is split into.
41///
42/// The discriminants are the span indices a [`TimestampRing`] is driven with,
43/// and the order is the order a frame line reports them in — both are wire
44/// contract, so a variant is appended, never re-ordered.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
46pub enum EngineSpan {
47    /// Work recorded strictly ahead of the frame's own passes: the glyph-atlas
48    /// replay that draws newly cached glyphs into their array layers.
49    Prepass = 0,
50    /// The frame's own surface passes — the clear, the opaque depth-writing
51    /// strip pass, every surface round's alpha strips, and the hole punch.
52    Main = 1,
53    /// Everything drawn into an off-screen page rather than the surface: each
54    /// isolated layer's page round and each pass of a filter's sequence.
55    Composite = 2,
56    /// The host's own present-side conversion — the un-premultiplying pass a
57    /// straight-alpha swapchain needs, or a blit into it.
58    Blit = 3,
59}
60
61impl EngineSpan {
62    /// Every span, in reporting order.
63    pub const ALL: [EngineSpan; 4] = [
64        EngineSpan::Prepass,
65        EngineSpan::Main,
66        EngineSpan::Composite,
67        EngineSpan::Blit,
68    ];
69
70    /// How many spans a frame is split into — what a caller sizes a
71    /// [`TimestampRing`] with.
72    pub const COUNT: usize = EngineSpan::ALL.len();
73
74    /// This span's index in a frame's reading.
75    #[must_use]
76    pub const fn index(self) -> usize {
77        self as usize
78    }
79
80    /// This span's stable name, as a frame line's field spells it.
81    #[must_use]
82    pub const fn name(self) -> &'static str {
83        match self {
84            EngineSpan::Prepass => "prepass",
85            EngineSpan::Main => "main",
86            EngineSpan::Composite => "composite",
87            EngineSpan::Blit => "blit",
88        }
89    }
90}
91
92/// The per-frame timestamp sink the renderer records its pass boundaries
93/// through — a borrow of the host's [`TimestampRing`], or nothing at all.
94///
95/// Copy and one pointer wide, so it is threaded through the frame walk by
96/// value: every pass site asks it for its own `timestamp_writes` and takes
97/// `None` for an answer without a branch of its own. An inert sink
98/// ([`FrameTimestamps::default`]) is what an ordinary
99/// [`crate::EngineRenderer::encode`] passes, so the untimed frame path is the
100/// timed one with every answer `None`.
101#[derive(Debug, Clone, Copy, Default)]
102pub struct FrameTimestamps<'a> {
103    ring: Option<&'a TimestampRing>,
104}
105
106impl<'a> FrameTimestamps<'a> {
107    /// A sink that times nothing.
108    #[must_use]
109    pub const fn inert() -> Self {
110        Self { ring: None }
111    }
112
113    /// A sink writing into `ring`, or an inert one when the ring is itself
114    /// inert — a host holding a ring for a device without `TIMESTAMP_QUERY`
115    /// records exactly the passes it always did.
116    #[must_use]
117    pub fn new(ring: &'a TimestampRing) -> Self {
118        Self {
119            ring: ring.is_active().then_some(ring),
120        }
121    }
122
123    /// Whether this sink writes anything at all.
124    #[must_use]
125    pub fn is_active(&self) -> bool {
126        self.ring.is_some()
127    }
128
129    /// The `timestamp_writes` for one pass belonging to `span`, or `None` for
130    /// an untimed pass. Every call takes a fresh query pair, so a span made of
131    /// several passes sums rather than overwriting itself.
132    #[must_use]
133    pub fn writes(&self, span: EngineSpan) -> Option<wgpu::RenderPassTimestampWrites<'a>> {
134        self.ring?.pass_writes(span.index())
135    }
136}
137
138/// Whether `source` labels every GPU object it creates `frust-engine <kind>`.
139///
140/// Answers `Err(offender)` naming the first label that does not, so the guard
141/// below reports the literal rather than only its file. Deliberately a literal
142/// scan rather than a parser: it recognises the two shapes this crate writes a
143/// label in — `label: Some("…")` at a `wgpu` descriptor and `label: "…"` at
144/// one of the renderer's own pass plans — which is what a new resource is
145/// added with.
146pub fn labels_are_prefixed(source: &str) -> Result<(), String> {
147    for line in source.lines() {
148        let trimmed = line.trim_start();
149        // A prose mention in a comment is not a label site.
150        if trimmed.starts_with("//") {
151            continue;
152        }
153        for opener in ["label: Some(\"", "label: \""] {
154            let Some(at) = trimmed.find(opener) else {
155                continue;
156            };
157            let rest = &trimmed[at + opener.len()..];
158            let Some(end) = rest.find('"') else {
159                continue;
160            };
161            let label = &rest[..end];
162            if !label.starts_with(LABEL_PREFIX) {
163                return Err(label.to_string());
164            }
165        }
166    }
167    Ok(())
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use std::path::{Path, PathBuf};
174
175    #[test]
176    fn span_indices_are_the_reporting_order() {
177        assert_eq!(EngineSpan::COUNT, 4);
178        for (index, span) in EngineSpan::ALL.into_iter().enumerate() {
179            assert_eq!(span.index(), index, "{span:?} must keep its wire index");
180        }
181        assert_eq!(EngineSpan::Prepass.index(), 0);
182        assert_eq!(EngineSpan::Blit.index(), 3);
183    }
184
185    #[test]
186    fn span_names_are_the_frame_lines_own_field_names() {
187        let names: Vec<&str> = EngineSpan::ALL.into_iter().map(EngineSpan::name).collect();
188        assert_eq!(names, ["prepass", "main", "composite", "blit"]);
189    }
190
191    #[test]
192    fn an_inert_sink_times_no_pass() {
193        let sink = FrameTimestamps::inert();
194        assert!(!sink.is_active());
195        for span in EngineSpan::ALL {
196            assert!(sink.writes(span).is_none());
197        }
198        assert!(!FrameTimestamps::default().is_active());
199    }
200
201    #[test]
202    fn a_sink_over_an_inert_ring_is_itself_inert() {
203        let ring = TimestampRing::inert(EngineSpan::COUNT);
204        let sink = FrameTimestamps::new(&ring);
205        assert!(!sink.is_active());
206        assert!(sink.writes(EngineSpan::Main).is_none());
207    }
208
209    #[test]
210    fn the_label_scan_accepts_both_shapes_this_crate_writes() {
211        assert!(labels_are_prefixed("    label: Some(\"frust-engine clear\"),").is_ok());
212        assert!(labels_are_prefixed("    label: \"frust-engine hole punch\",").is_ok());
213        assert_eq!(
214            labels_are_prefixed("    label: Some(\"scratch\"),"),
215            Err("scratch".to_string())
216        );
217        assert_eq!(
218            labels_are_prefixed("    label: \"page\","),
219            Err("page".to_string())
220        );
221    }
222
223    #[test]
224    fn the_label_scan_ignores_a_comment_mentioning_one() {
225        assert!(labels_are_prefixed("    // label: Some(\"anything\") is prose here").is_ok());
226    }
227
228    /// Blocks on `future` by polling it to completion — `wgpu`'s native
229    /// adapter and device requests resolve without an executor driving them,
230    /// and this crate has no async runtime of its own.
231    fn block_on<F: std::future::Future>(future: F) -> F::Output {
232        use std::task::{Context, Poll, Waker};
233
234        let waker = Waker::noop();
235        let mut cx = Context::from_waker(waker);
236        let mut future = std::pin::pin!(future);
237        loop {
238            match future.as_mut().poll(&mut cx) {
239                Poll::Ready(value) => return value,
240                Poll::Pending => std::thread::yield_now(),
241            }
242        }
243    }
244
245    #[test]
246    #[ignore = "needs a real GPU adapter offering TIMESTAMP_QUERY; run with \
247                `cargo test -p frust-engine -- --ignored` (pin the adapter on a \
248                multi-GPU host with WGPU_BACKEND / WGPU_ADAPTER_NAME)"]
249    fn a_real_frame_reports_plausible_per_span_gpu_time() {
250        use crate::{EngineRenderer, EngineTarget, OutputAlpha};
251        use frust_gpu::{HeadlessTarget, TierCaps};
252        use frust_scene::{Scene, SceneBuilder};
253        use kurbo::{Affine, Rect};
254        use peniko::Brush;
255        use peniko::color::palette::css;
256        use std::time::Duration;
257
258        const SIZE: u32 = 512;
259        const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
260
261        block_on(async {
262            let instance = wgpu::Instance::new(
263                wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
264            );
265            let adapter = wgpu::util::initialize_adapter_from_env_or_default(&instance, None)
266                .await
267                .expect("no compatible GPU adapter");
268            println!(
269                "frust-engine gpu-timestamp adapter: {:?}",
270                adapter.get_info()
271            );
272            let caps = TierCaps::probe(&adapter);
273            if !caps.has_timestamp_query {
274                println!("adapter offers no TIMESTAMP_QUERY — the inert path is the whole story");
275                return;
276            }
277            let (device, queue) = adapter
278                .request_device(&wgpu::DeviceDescriptor {
279                    label: Some("frust-engine gpu-timestamp device"),
280                    // Exactly what `frust_gpu::context`'s `required_features`
281                    // asks for under a `perf-trace` build on this adapter.
282                    required_features: wgpu::Features::TIMESTAMP_QUERY,
283                    required_limits: wgpu::Limits::default(),
284                    ..Default::default()
285                })
286                .await
287                .expect("failed to create the device");
288
289            let mut renderer = EngineRenderer::new(&device, &caps, FORMAT, None)
290                .expect("engine renderer for a headless target");
291            renderer.finish_warm_up(&device);
292            let target = HeadlessTarget::new(&device, SIZE, SIZE, FORMAT);
293            let mut ring = frust_gpu::diag::TimestampRing::new(
294                &device,
295                &queue,
296                EngineSpan::COUNT,
297                TIMESTAMP_RING_LABEL,
298            );
299            assert!(
300                ring.is_active(),
301                "a TIMESTAMP_QUERY device must arm the ring"
302            );
303
304            // Surface draws (Main) plus a sub-unity layer, which the scheduler
305            // renders into its own page and composites back (Composite). Big
306            // rectangles, so both spans are comfortably above the clock's
307            // resolution rather than sitting in its noise.
308            let mut scene = Scene::new();
309            let mut builder = SceneBuilder::new(&mut scene);
310            builder.fill_rect(
311                Rect::new(0.0, 0.0, f64::from(SIZE), f64::from(SIZE)),
312                Brush::Solid(css::REBECCA_PURPLE),
313            );
314            builder.fill_rect(
315                Rect::new(16.0, 16.0, 480.0, 480.0),
316                Brush::Solid(css::CORNFLOWER_BLUE.with_alpha(0.5)),
317            );
318            builder.push_layer(Rect::new(32.0, 32.0, 464.0, 464.0), 0.5);
319            builder.fill_rect(
320                Rect::new(48.0, 48.0, 448.0, 448.0),
321                Brush::Solid(css::ORANGE),
322            );
323            builder.pop_layer();
324
325            let mut readings = 0_u64;
326            for frame in 0..(frust_gpu::diag::RING_FRAMES * 3) {
327                ring.begin_frame(&device);
328                let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
329                    label: Some("frust-engine gpu-timestamp frame"),
330                });
331                renderer
332                    .encode_traced(
333                        &device,
334                        &queue,
335                        &mut encoder,
336                        &scene,
337                        EngineTarget {
338                            view: target.view(),
339                            format: FORMAT,
340                            width: SIZE,
341                            height: SIZE,
342                            depth: None,
343                            output: OutputAlpha::Premultiplied,
344                        },
345                        css::BLACK,
346                        Affine::IDENTITY,
347                        FrameTimestamps::new(&ring),
348                    )
349                    .expect("the engine must serve this frame");
350                ring.resolve(&mut encoder);
351                queue.submit([encoder.finish()]);
352                ring.end_frame();
353                renderer.end_frame(&queue);
354                // The frame path never waits on a map; a test may, and does.
355                let _ = device.poll(wgpu::PollType::wait_indefinitely());
356                if ring.harvested() > readings {
357                    readings = ring.harvested();
358                    let reading = ring.latest().expect("a harvest produces a reading");
359                    println!(
360                        "frame {frame}: main={:?} composite={:?} total={:?}",
361                        reading.span(EngineSpan::Main.index()),
362                        reading.span(EngineSpan::Composite.index()),
363                        reading.total()
364                    );
365                }
366            }
367
368            let reading = ring
369                .latest()
370                .expect("at least one reading must have landed");
371            assert_eq!(reading.len(), EngineSpan::COUNT);
372            assert!(
373                reading.span(EngineSpan::Main.index()) > Duration::ZERO,
374                "the frame's own surface passes drew: {reading:?}"
375            );
376            assert!(
377                reading.span(EngineSpan::Composite.index()) > Duration::ZERO,
378                "the sub-unity layer rendered into a page: {reading:?}"
379            );
380            // The two spans this build records; the other two need a pass
381            // descriptor seam their own modules do not carry yet.
382            assert_eq!(
383                reading.total(),
384                reading.span(EngineSpan::Main.index())
385                    + reading.span(EngineSpan::Composite.index())
386                    + reading.span(EngineSpan::Prepass.index())
387                    + reading.span(EngineSpan::Blit.index())
388            );
389            assert!(
390                reading.total() < Duration::from_millis(100),
391                "one 512x512 frame cannot plausibly take {:?}",
392                reading.total()
393            );
394        });
395    }
396
397    /// Every `.rs` file under `dir`, recursively.
398    fn rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
399        let Ok(entries) = std::fs::read_dir(dir) else {
400            return;
401        };
402        for entry in entries.flatten() {
403            let path = entry.path();
404            if path.is_dir() {
405                rust_files(&path, out);
406            } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
407                out.push(path);
408            }
409        }
410    }
411
412    #[test]
413    fn every_gpu_object_this_crate_creates_is_labelled_frust_engine() {
414        // The mechanical half of the labelling rule: a texture, buffer,
415        // pipeline or pass added without the prefix fails here rather than
416        // showing up anonymous in someone's GPU capture months later.
417        let src = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
418        let mut files = Vec::new();
419        rust_files(&src, &mut files);
420        files.sort();
421        assert!(!files.is_empty(), "{} has no sources", src.display());
422
423        let mut failures = Vec::new();
424        for path in files {
425            let Ok(contents) = std::fs::read_to_string(&path) else {
426                continue;
427            };
428            if let Err(label) = labels_are_prefixed(&contents) {
429                failures.push(format!("{}: `{label}`", path.display()));
430            }
431        }
432        assert!(
433            failures.is_empty(),
434            "every GPU object must be labelled `{LABEL_PREFIX}<kind>`: {failures:?}"
435        );
436    }
437}