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