Skip to main content

cranpose_render_wgpu/
gpu_stats.rs

1//! Per-frame GPU render counters.
2//!
3//! Counters are always collected so tests and perf harnesses can assert them.
4//! Setting `CRANPOSE_GPU_STATS=1` prints a summary line every 60 frames to stderr.
5
6use crate::frame_graph::FrameCommandStats;
7use crate::surface_requirements::{SurfaceRequirement, SurfaceRequirementSet};
8use std::cell::{Cell, RefCell};
9
10use cranpose_core::NodeId;
11use cranpose_ui_graphics::Rect;
12
13const TOP_ISOLATED_LAYER_LIMIT: usize = 8;
14
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16pub struct LayerSurfaceReasons {
17    pub explicit_offscreen: bool,
18    pub effect: bool,
19    pub backdrop: bool,
20    pub group_opacity: bool,
21    pub blend_mode: bool,
22    pub shape_clip: bool,
23    pub immediate_shadow: bool,
24    pub text_local_surface: bool,
25    pub motion_stable_capture: bool,
26    pub mixed_direct_content: bool,
27    pub non_translation_transform: bool,
28    pub pixel_stable_composite: bool,
29}
30
31impl LayerSurfaceReasons {
32    /// Returns true if any isolating requirement is set.
33    /// Delegates to `SurfaceRequirementSet::has_isolating_requirement` to
34    /// keep the semantics in sync (e.g. `mixed_direct_content` alone does
35    /// not count as isolating).
36    pub fn has_any(self) -> bool {
37        self.explicit_offscreen
38            || self.effect
39            || self.backdrop
40            || self.group_opacity
41            || self.blend_mode
42            || self.shape_clip
43            || self.text_local_surface
44            || self.motion_stable_capture
45            || self.non_translation_transform
46    }
47
48    pub fn labels(self) -> impl Iterator<Item = &'static str> {
49        let mut labels = [None; 12];
50        let mut len = 0usize;
51
52        if self.explicit_offscreen {
53            labels[len] = Some("explicit_offscreen");
54            len += 1;
55        }
56        if self.effect {
57            labels[len] = Some("effect");
58            len += 1;
59        }
60        if self.backdrop {
61            labels[len] = Some("backdrop");
62            len += 1;
63        }
64        if self.group_opacity {
65            labels[len] = Some("group_opacity");
66            len += 1;
67        }
68        if self.blend_mode {
69            labels[len] = Some("blend_mode");
70            len += 1;
71        }
72        if self.shape_clip {
73            labels[len] = Some("shape_clip");
74            len += 1;
75        }
76        if self.immediate_shadow {
77            labels[len] = Some("immediate_shadow");
78            len += 1;
79        }
80        if self.text_local_surface {
81            labels[len] = Some("text_local_surface");
82            len += 1;
83        }
84        if self.motion_stable_capture {
85            labels[len] = Some("motion_stable_capture");
86            len += 1;
87        }
88        if self.mixed_direct_content {
89            labels[len] = Some("mixed_direct_content");
90            len += 1;
91        }
92        if self.non_translation_transform {
93            labels[len] = Some("non_translation_transform");
94            len += 1;
95        }
96        if self.pixel_stable_composite {
97            labels[len] = Some("pixel_stable_composite");
98            len += 1;
99        }
100
101        labels.into_iter().flatten().take(len)
102    }
103
104    pub fn display(self) -> String {
105        let mut joined = String::new();
106        for (index, label) in self.labels().enumerate() {
107            if index > 0 {
108                joined.push('+');
109            }
110            joined.push_str(label);
111        }
112        if joined.is_empty() {
113            joined.push_str("none");
114        }
115        joined
116    }
117
118    pub fn has_renderer_forced_surface(self) -> bool {
119        self.text_local_surface || self.non_translation_transform
120    }
121}
122
123impl From<SurfaceRequirementSet> for LayerSurfaceReasons {
124    fn from(requirements: SurfaceRequirementSet) -> Self {
125        Self {
126            explicit_offscreen: requirements.contains(SurfaceRequirement::ExplicitOffscreen),
127            effect: requirements.contains(SurfaceRequirement::RenderEffect),
128            backdrop: requirements.contains(SurfaceRequirement::Backdrop),
129            group_opacity: requirements.contains(SurfaceRequirement::GroupOpacity),
130            blend_mode: requirements.contains(SurfaceRequirement::BlendMode),
131            shape_clip: requirements.contains(SurfaceRequirement::ShapeClip),
132            immediate_shadow: requirements.contains(SurfaceRequirement::ImmediateShadow),
133            text_local_surface: requirements.contains(SurfaceRequirement::TextMaterialMask),
134            motion_stable_capture: requirements.contains(SurfaceRequirement::MotionStableCapture),
135            mixed_direct_content: requirements.contains(SurfaceRequirement::MixedDirectContent),
136            non_translation_transform: requirements
137                .contains(SurfaceRequirement::NonTranslationTransform),
138            pixel_stable_composite: requirements.contains(SurfaceRequirement::PixelStableComposite),
139        }
140    }
141}
142
143#[derive(Clone, Copy, Debug, PartialEq)]
144pub struct IsolatedLayerStat {
145    pub node_id: Option<NodeId>,
146    pub logical_rect: Rect,
147    pub width: u32,
148    pub height: u32,
149    pub reasons: LayerSurfaceReasons,
150}
151
152impl IsolatedLayerStat {
153    fn pixel_area(self) -> u64 {
154        (self.width as u64) * (self.height as u64)
155    }
156}
157
158impl Default for IsolatedLayerStat {
159    fn default() -> Self {
160        Self {
161            node_id: None,
162            logical_rect: Rect {
163                x: 0.0,
164                y: 0.0,
165                width: 0.0,
166                height: 0.0,
167            },
168            width: 0,
169            height: 0,
170            reasons: LayerSurfaceReasons::default(),
171        }
172    }
173}
174
175#[derive(Clone, Copy, Debug, Default, PartialEq)]
176pub struct FrameStatsSnapshot {
177    pub submits: u32,
178    pub encoder_count: u32,
179    pub submit_count: u32,
180    pub pass_count: u32,
181    pub offscreen_acquires: u32,
182    pub offscreen_news: u32,
183    pub offscreen_total_bytes: u64,
184    pub transient_texture_bytes: u64,
185    pub retained_texture_bytes: u64,
186    pub upload_bytes: u64,
187    pub isolated_layer_renders: u32,
188    pub isolated_layer_pixels: u64,
189    pub layer_cache_hits: u32,
190    pub layer_cache_misses: u32,
191    pub layer_cache_evictions: u32,
192    pub layer_cache_hit_pixels: u64,
193    pub layer_cache_miss_pixels: u64,
194    pub shadow_shape_cache_hits: u32,
195    pub shadow_shape_cache_misses: u32,
196    pub shadow_shape_cache_hit_pixels: u64,
197    pub shadow_shape_cache_miss_pixels: u64,
198    pub shadow_text_blur_fallbacks: u32,
199    pub blur_passes: u32,
200    pub composite_passes: u32,
201    pub effect_applies: u32,
202    pub shape_passes: u32,
203    pub image_passes: u32,
204    pub text_passes: u32,
205    /// Shape, image and glyph `draw_indexed` calls recorded this frame. The
206    /// `*_passes` counters above count *batches*, so a single image batch
207    /// reports `image_passes=1` however many images it draws; this counts what
208    /// the driver actually sees. Composite and effect quads are not included —
209    /// they are one draw each and already counted by `composite_passes`,
210    /// `blur_passes` and `effect_applies`.
211    pub draw_calls: u32,
212    pub text_image_cache_hits: u32,
213    pub text_image_cache_misses: u32,
214    pub text_image_cache_hit_pixels: u64,
215    pub text_image_cache_miss_pixels: u64,
216    pub text_image_raster_bytes: u64,
217    pub text_glyph_atlas_hits: u32,
218    pub text_glyph_atlas_misses: u32,
219    pub text_glyph_atlas_miss_pixels: u64,
220    pub offscreen_pool_size: u32,
221    pub offscreen_pool_bytes: u64,
222    pub text_pool_size: u32,
223    pub layer_cache_size: u32,
224    pub layer_cache_bytes: u64,
225    pub image_cache_size: u32,
226    pub text_cache_size: u32,
227    pub top_isolated_layers: [Option<IsolatedLayerStat>; TOP_ISOLATED_LAYER_LIMIT],
228    pub top_isolated_layer_count: usize,
229}
230
231impl FrameStatsSnapshot {
232    pub(crate) fn with_command_stats_added(mut self, stats: FrameCommandStats) -> Self {
233        self.submits = self.submits.saturating_add(stats.submit_count);
234        self.encoder_count = self.encoder_count.saturating_add(stats.encoder_count);
235        self.submit_count = self.submit_count.saturating_add(stats.submit_count);
236        self.pass_count = self.pass_count.saturating_add(stats.pass_count);
237        self.transient_texture_bytes = self
238            .transient_texture_bytes
239            .saturating_add(stats.transient_texture_bytes);
240        self.retained_texture_bytes = self
241            .retained_texture_bytes
242            .max(stats.retained_texture_bytes);
243        self.upload_bytes = self.upload_bytes.saturating_add(stats.upload_bytes);
244        self
245    }
246
247    pub fn top_isolated_layers(self) -> impl Iterator<Item = IsolatedLayerStat> {
248        self.top_isolated_layers
249            .into_iter()
250            .flatten()
251            .take(self.top_isolated_layer_count)
252    }
253
254    fn layer_cache_hit_rate(self) -> f64 {
255        let total = self.layer_cache_hits + self.layer_cache_misses;
256        if total > 0 {
257            (self.layer_cache_hits as f64 / total as f64) * 100.0
258        } else {
259            0.0
260        }
261    }
262
263    fn print(self, frame_count: u64) {
264        let mb = self.offscreen_total_bytes as f64 / (1024.0 * 1024.0);
265        let upload_mb = self.upload_bytes as f64 / (1024.0 * 1024.0);
266        let retained_mb = self.retained_texture_bytes as f64 / (1024.0 * 1024.0);
267        let pool_mb = self.offscreen_pool_bytes as f64 / (1024.0 * 1024.0);
268        let layer_cache_hit_mpx = self.layer_cache_hit_pixels as f64 / 1_000_000.0;
269        let layer_cache_miss_mpx = self.layer_cache_miss_pixels as f64 / 1_000_000.0;
270        let shadow_cache_hit_mpx = self.shadow_shape_cache_hit_pixels as f64 / 1_000_000.0;
271        let shadow_cache_miss_mpx = self.shadow_shape_cache_miss_pixels as f64 / 1_000_000.0;
272        let layer_cache_mb = self.layer_cache_bytes as f64 / (1024.0 * 1024.0);
273        let isolated_layer_mpx = self.isolated_layer_pixels as f64 / 1_000_000.0;
274        eprintln!(
275            "[GPU f#{}] encoders={} submits={} passes={} | offscreen: acq={} new={} {:.1}MB pool={}({:.1}MB) retained={:.1}MB | \
276             uploads={:.2}MB | \
277             isolated_layers={} area={:.2}MP | \
278             layer_cache: hit={} miss={} {:.1}% evict={} hit_px={:.2}MP miss_px={:.2}MP size={}({:.1}MB) | \
279             shadow_cache: shape_hit={} shape_miss={} hit_px={:.2}MP miss_px={:.2}MP text_blur_fallback={} | \
280             blur={} composite={} effect={} | shape={} image={} text={} draws={} | \
281             text_img_cache: hit={} miss={} hit_px={:.2}MP miss_px={:.2}MP raster={:.2}MB | \
282             text_glyph_atlas: hit={} miss={} miss_px={:.2}MP | \
283             caches: text_pool={} img={} txt={}",
284            frame_count,
285            self.encoder_count,
286            self.submit_count,
287            self.pass_count,
288            self.offscreen_acquires,
289            self.offscreen_news,
290            mb,
291            self.offscreen_pool_size,
292            pool_mb,
293            retained_mb,
294            upload_mb,
295            self.isolated_layer_renders,
296            isolated_layer_mpx,
297            self.layer_cache_hits,
298            self.layer_cache_misses,
299            self.layer_cache_hit_rate(),
300            self.layer_cache_evictions,
301            layer_cache_hit_mpx,
302            layer_cache_miss_mpx,
303            self.layer_cache_size,
304            layer_cache_mb,
305            self.shadow_shape_cache_hits,
306            self.shadow_shape_cache_misses,
307            shadow_cache_hit_mpx,
308            shadow_cache_miss_mpx,
309            self.shadow_text_blur_fallbacks,
310            self.blur_passes,
311            self.composite_passes,
312            self.effect_applies,
313            self.shape_passes,
314            self.image_passes,
315            self.text_passes,
316            self.draw_calls,
317            self.text_image_cache_hits,
318            self.text_image_cache_misses,
319            self.text_image_cache_hit_pixels as f64 / 1_000_000.0,
320            self.text_image_cache_miss_pixels as f64 / 1_000_000.0,
321            self.text_image_raster_bytes as f64 / (1024.0 * 1024.0),
322            self.text_glyph_atlas_hits,
323            self.text_glyph_atlas_misses,
324            self.text_glyph_atlas_miss_pixels as f64 / 1_000_000.0,
325            self.text_pool_size,
326            self.image_cache_size,
327            self.text_cache_size,
328        );
329        for (index, layer) in self.top_isolated_layers().enumerate() {
330            eprintln!(
331                "  [isolated #{index}] node={:?} rect=({:.1},{:.1},{:.1},{:.1}) target={}x{} reasons={}",
332                layer.node_id,
333                layer.logical_rect.x,
334                layer.logical_rect.y,
335                layer.logical_rect.width,
336                layer.logical_rect.height,
337                layer.width,
338                layer.height,
339                layer.reasons.display(),
340            );
341        }
342    }
343}
344
345/// Per-frame debug counters for GPU work instrumentation.
346/// Uses `Cell` fields so counters can be bumped through shared references.
347#[derive(Default)]
348pub(crate) struct FrameStats {
349    pub submits: Cell<u32>,
350    pub command_encoder_count: Cell<u32>,
351    pub command_submit_count: Cell<u32>,
352    pub command_pass_count: Cell<u32>,
353    pub command_transient_texture_bytes: Cell<u64>,
354    pub command_retained_texture_bytes: Cell<u64>,
355    pub command_upload_bytes: Cell<u64>,
356    pub offscreen_acquires: Cell<u32>,
357    pub offscreen_news: Cell<u32>,
358    pub offscreen_total_bytes: Cell<u64>,
359    pub upload_bytes: Cell<u64>,
360    pub isolated_layer_renders: Cell<u32>,
361    pub isolated_layer_pixels: Cell<u64>,
362    pub layer_cache_hits: Cell<u32>,
363    pub layer_cache_misses: Cell<u32>,
364    pub layer_cache_evictions: Cell<u32>,
365    pub layer_cache_hit_pixels: Cell<u64>,
366    pub layer_cache_miss_pixels: Cell<u64>,
367    pub shadow_shape_cache_hits: Cell<u32>,
368    pub shadow_shape_cache_misses: Cell<u32>,
369    pub shadow_shape_cache_hit_pixels: Cell<u64>,
370    pub shadow_shape_cache_miss_pixels: Cell<u64>,
371    pub shadow_text_blur_fallbacks: Cell<u32>,
372    pub blur_passes: Cell<u32>,
373    pub composite_passes: Cell<u32>,
374    pub effect_applies: Cell<u32>,
375    pub shape_passes: Cell<u32>,
376    pub image_passes: Cell<u32>,
377    pub text_passes: Cell<u32>,
378    pub draw_calls: Cell<u32>,
379    pub text_image_cache_hits: Cell<u32>,
380    pub text_image_cache_misses: Cell<u32>,
381    pub text_image_cache_hit_pixels: Cell<u64>,
382    pub text_image_cache_miss_pixels: Cell<u64>,
383    pub text_image_raster_bytes: Cell<u64>,
384    pub text_glyph_atlas_hits: Cell<u32>,
385    pub text_glyph_atlas_misses: Cell<u32>,
386    pub text_glyph_atlas_miss_pixels: Cell<u64>,
387    // Pool/cache sizes snapshotted at end of frame
388    pub offscreen_pool_size: Cell<u32>,
389    pub offscreen_pool_bytes: Cell<u64>,
390    pub text_pool_size: Cell<u32>,
391    pub layer_cache_size: Cell<u32>,
392    pub layer_cache_bytes: Cell<u64>,
393    pub image_cache_size: Cell<u32>,
394    pub text_cache_size: Cell<u32>,
395    top_isolated_layers: RefCell<[Option<IsolatedLayerStat>; TOP_ISOLATED_LAYER_LIMIT]>,
396    top_isolated_layer_count: Cell<usize>,
397    shadow_shape_cache_miss_log_count: Cell<u32>,
398}
399
400impl FrameStats {
401    pub fn record_command_stats(&self, stats: FrameCommandStats) {
402        self.submits
403            .set(self.submits.get().saturating_add(stats.submit_count));
404        self.command_encoder_count.set(
405            self.command_encoder_count
406                .get()
407                .saturating_add(stats.encoder_count),
408        );
409        self.command_submit_count.set(
410            self.command_submit_count
411                .get()
412                .saturating_add(stats.submit_count),
413        );
414        self.command_pass_count.set(
415            self.command_pass_count
416                .get()
417                .saturating_add(stats.pass_count),
418        );
419        self.command_transient_texture_bytes.set(
420            self.command_transient_texture_bytes
421                .get()
422                .saturating_add(stats.transient_texture_bytes),
423        );
424        self.command_retained_texture_bytes.set(
425            self.command_retained_texture_bytes
426                .get()
427                .max(stats.retained_texture_bytes),
428        );
429        self.command_upload_bytes.set(
430            self.command_upload_bytes
431                .get()
432                .saturating_add(stats.upload_bytes),
433        );
434    }
435
436    pub fn record_offscreen_acquire(
437        &self,
438        width: u32,
439        height: u32,
440        format: wgpu::TextureFormat,
441        is_new: bool,
442    ) {
443        self.offscreen_acquires
444            .set(self.offscreen_acquires.get() + 1);
445        if is_new {
446            self.offscreen_news.set(self.offscreen_news.get() + 1);
447        }
448        self.offscreen_total_bytes.set(
449            self.offscreen_total_bytes.get()
450                + (width as u64)
451                    * (height as u64)
452                    * crate::frame_graph::texture_format_bytes_per_pixel(format),
453        );
454    }
455
456    pub fn record_upload_bytes(&self, bytes: u64) {
457        self.upload_bytes
458            .set(self.upload_bytes.get().saturating_add(bytes));
459    }
460
461    pub fn record_isolated_layer_render(
462        &self,
463        width: u32,
464        height: u32,
465        node_id: Option<NodeId>,
466        logical_rect: Rect,
467        reasons: LayerSurfaceReasons,
468    ) {
469        self.isolated_layer_renders
470            .set(self.isolated_layer_renders.get().saturating_add(1));
471        self.isolated_layer_pixels.set(
472            self.isolated_layer_pixels
473                .get()
474                .saturating_add((width as u64) * (height as u64)),
475        );
476        self.record_top_isolated_layer(IsolatedLayerStat {
477            node_id,
478            logical_rect,
479            width,
480            height,
481            reasons,
482        });
483    }
484
485    pub fn record_layer_cache_hit(&self, width: u32, height: u32) {
486        self.layer_cache_hits
487            .set(self.layer_cache_hits.get().saturating_add(1));
488        self.layer_cache_hit_pixels.set(
489            self.layer_cache_hit_pixels
490                .get()
491                .saturating_add((width as u64) * (height as u64)),
492        );
493    }
494
495    pub fn record_layer_cache_miss(&self, width: u32, height: u32) {
496        self.layer_cache_misses
497            .set(self.layer_cache_misses.get().saturating_add(1));
498        self.layer_cache_miss_pixels.set(
499            self.layer_cache_miss_pixels
500                .get()
501                .saturating_add((width as u64) * (height as u64)),
502        );
503    }
504
505    pub fn record_layer_cache_eviction(&self) {
506        self.layer_cache_evictions
507            .set(self.layer_cache_evictions.get().saturating_add(1));
508    }
509
510    pub fn record_shadow_shape_cache_hit(&self, width: u32, height: u32) {
511        self.shadow_shape_cache_hits
512            .set(self.shadow_shape_cache_hits.get().saturating_add(1));
513        self.shadow_shape_cache_hit_pixels.set(
514            self.shadow_shape_cache_hit_pixels
515                .get()
516                .saturating_add((width as u64) * (height as u64)),
517        );
518    }
519
520    pub fn record_shadow_shape_cache_miss(&self, width: u32, height: u32) {
521        self.shadow_shape_cache_misses
522            .set(self.shadow_shape_cache_misses.get().saturating_add(1));
523        self.shadow_shape_cache_miss_pixels.set(
524            self.shadow_shape_cache_miss_pixels
525                .get()
526                .saturating_add((width as u64) * (height as u64)),
527        );
528    }
529
530    #[allow(clippy::too_many_arguments)]
531    pub fn maybe_print_shadow_shape_cache_miss(
532        &self,
533        width: u32,
534        height: u32,
535        content_hash: u64,
536        blur_radius: f32,
537        viewport_offset: [f32; 2],
538        shape_count: usize,
539        clip: Option<Rect>,
540    ) {
541        if !shadow_cache_diagnostics_enabled() {
542            return;
543        }
544
545        let count = self.shadow_shape_cache_miss_log_count.get();
546        if count >= 16 {
547            return;
548        }
549        self.shadow_shape_cache_miss_log_count.set(count + 1);
550
551        let clip_text = clip
552            .map(|clip| {
553                format!(
554                    "({:.1},{:.1},{:.1},{:.1})",
555                    clip.x, clip.y, clip.width, clip.height
556                )
557            })
558            .unwrap_or_else(|| "none".to_string());
559        eprintln!(
560            "[shadow-cache-miss #{count}] size={}x{} content_hash={content_hash} blur={:.2} viewport_offset=({:.1},{:.1}) shapes={} clip={}",
561            width,
562            height,
563            blur_radius,
564            viewport_offset[0],
565            viewport_offset[1],
566            shape_count,
567            clip_text,
568        );
569    }
570
571    pub fn record_shadow_text_blur_fallback(&self) {
572        self.shadow_text_blur_fallbacks
573            .set(self.shadow_text_blur_fallbacks.get().saturating_add(1));
574    }
575
576    pub fn bump_shapes(&self) {
577        self.shape_passes.set(self.shape_passes.get() + 1);
578    }
579
580    pub fn bump_images(&self) {
581        self.image_passes.set(self.image_passes.get() + 1);
582    }
583
584    /// Records `count` `draw`/`draw_indexed` calls. Call sites bump this once
585    /// per batch with the batch's draw count rather than once per draw, so the
586    /// counter costs one `Cell` update per batch rather than one per primitive.
587    pub fn add_draw_calls(&self, count: u32) {
588        self.draw_calls
589            .set(self.draw_calls.get().saturating_add(count));
590    }
591
592    pub fn bump_text(&self) {
593        self.text_passes.set(self.text_passes.get() + 1);
594    }
595
596    pub fn record_text_image_cache_hit(&self, width: u32, height: u32) {
597        self.text_image_cache_hits
598            .set(self.text_image_cache_hits.get().saturating_add(1));
599        self.text_image_cache_hit_pixels.set(
600            self.text_image_cache_hit_pixels
601                .get()
602                .saturating_add((width as u64) * (height as u64)),
603        );
604    }
605
606    pub fn record_text_image_cache_miss(&self, width: u32, height: u32) {
607        let pixels = (width as u64) * (height as u64);
608        self.text_image_cache_misses
609            .set(self.text_image_cache_misses.get().saturating_add(1));
610        self.text_image_cache_miss_pixels.set(
611            self.text_image_cache_miss_pixels
612                .get()
613                .saturating_add(pixels),
614        );
615        self.text_image_raster_bytes.set(
616            self.text_image_raster_bytes
617                .get()
618                .saturating_add(pixels * 4),
619        );
620    }
621
622    pub fn record_text_glyph_atlas_hit(&self) {
623        self.text_glyph_atlas_hits
624            .set(self.text_glyph_atlas_hits.get().saturating_add(1));
625    }
626
627    pub fn record_text_glyph_atlas_miss(&self, width: u32, height: u32) {
628        self.text_glyph_atlas_misses
629            .set(self.text_glyph_atlas_misses.get().saturating_add(1));
630        self.text_glyph_atlas_miss_pixels.set(
631            self.text_glyph_atlas_miss_pixels
632                .get()
633                .saturating_add((width as u64) * (height as u64)),
634        );
635    }
636
637    pub fn snapshot(&self) -> FrameStatsSnapshot {
638        let retained_texture_bytes = self
639            .offscreen_pool_bytes
640            .get()
641            .saturating_add(self.layer_cache_bytes.get());
642        FrameStatsSnapshot {
643            submits: self.submits.get(),
644            encoder_count: self.command_encoder_count.get(),
645            submit_count: self.command_submit_count.get(),
646            pass_count: self.command_pass_count.get(),
647            offscreen_acquires: self.offscreen_acquires.get(),
648            offscreen_news: self.offscreen_news.get(),
649            offscreen_total_bytes: self.offscreen_total_bytes.get(),
650            transient_texture_bytes: self
651                .offscreen_total_bytes
652                .get()
653                .saturating_add(self.command_transient_texture_bytes.get()),
654            retained_texture_bytes: retained_texture_bytes
655                .saturating_add(self.command_retained_texture_bytes.get()),
656            upload_bytes: self
657                .upload_bytes
658                .get()
659                .saturating_add(self.command_upload_bytes.get()),
660            isolated_layer_renders: self.isolated_layer_renders.get(),
661            isolated_layer_pixels: self.isolated_layer_pixels.get(),
662            layer_cache_hits: self.layer_cache_hits.get(),
663            layer_cache_misses: self.layer_cache_misses.get(),
664            layer_cache_evictions: self.layer_cache_evictions.get(),
665            layer_cache_hit_pixels: self.layer_cache_hit_pixels.get(),
666            layer_cache_miss_pixels: self.layer_cache_miss_pixels.get(),
667            shadow_shape_cache_hits: self.shadow_shape_cache_hits.get(),
668            shadow_shape_cache_misses: self.shadow_shape_cache_misses.get(),
669            shadow_shape_cache_hit_pixels: self.shadow_shape_cache_hit_pixels.get(),
670            shadow_shape_cache_miss_pixels: self.shadow_shape_cache_miss_pixels.get(),
671            shadow_text_blur_fallbacks: self.shadow_text_blur_fallbacks.get(),
672            blur_passes: self.blur_passes.get(),
673            composite_passes: self.composite_passes.get(),
674            effect_applies: self.effect_applies.get(),
675            shape_passes: self.shape_passes.get(),
676            image_passes: self.image_passes.get(),
677            text_passes: self.text_passes.get(),
678            draw_calls: self.draw_calls.get(),
679            text_image_cache_hits: self.text_image_cache_hits.get(),
680            text_image_cache_misses: self.text_image_cache_misses.get(),
681            text_image_cache_hit_pixels: self.text_image_cache_hit_pixels.get(),
682            text_image_cache_miss_pixels: self.text_image_cache_miss_pixels.get(),
683            text_image_raster_bytes: self.text_image_raster_bytes.get(),
684            text_glyph_atlas_hits: self.text_glyph_atlas_hits.get(),
685            text_glyph_atlas_misses: self.text_glyph_atlas_misses.get(),
686            text_glyph_atlas_miss_pixels: self.text_glyph_atlas_miss_pixels.get(),
687            offscreen_pool_size: self.offscreen_pool_size.get(),
688            offscreen_pool_bytes: self.offscreen_pool_bytes.get(),
689            text_pool_size: self.text_pool_size.get(),
690            layer_cache_size: self.layer_cache_size.get(),
691            layer_cache_bytes: self.layer_cache_bytes.get(),
692            image_cache_size: self.image_cache_size.get(),
693            text_cache_size: self.text_cache_size.get(),
694            top_isolated_layers: *self.top_isolated_layers.borrow(),
695            top_isolated_layer_count: self.top_isolated_layer_count.get(),
696        }
697    }
698
699    pub fn reset(&self) {
700        self.submits.set(0);
701        self.command_encoder_count.set(0);
702        self.command_submit_count.set(0);
703        self.command_pass_count.set(0);
704        self.command_transient_texture_bytes.set(0);
705        self.command_retained_texture_bytes.set(0);
706        self.command_upload_bytes.set(0);
707        self.offscreen_acquires.set(0);
708        self.offscreen_news.set(0);
709        self.offscreen_total_bytes.set(0);
710        self.upload_bytes.set(0);
711        self.isolated_layer_renders.set(0);
712        self.isolated_layer_pixels.set(0);
713        self.layer_cache_hits.set(0);
714        self.layer_cache_misses.set(0);
715        self.layer_cache_evictions.set(0);
716        self.layer_cache_hit_pixels.set(0);
717        self.layer_cache_miss_pixels.set(0);
718        self.shadow_shape_cache_hits.set(0);
719        self.shadow_shape_cache_misses.set(0);
720        self.shadow_shape_cache_hit_pixels.set(0);
721        self.shadow_shape_cache_miss_pixels.set(0);
722        self.shadow_text_blur_fallbacks.set(0);
723        self.blur_passes.set(0);
724        self.composite_passes.set(0);
725        self.effect_applies.set(0);
726        self.shape_passes.set(0);
727        self.image_passes.set(0);
728        self.text_passes.set(0);
729        self.draw_calls.set(0);
730        self.text_image_cache_hits.set(0);
731        self.text_image_cache_misses.set(0);
732        self.text_image_cache_hit_pixels.set(0);
733        self.text_image_cache_miss_pixels.set(0);
734        self.text_image_raster_bytes.set(0);
735        self.text_glyph_atlas_hits.set(0);
736        self.text_glyph_atlas_misses.set(0);
737        self.text_glyph_atlas_miss_pixels.set(0);
738        *self.top_isolated_layers.borrow_mut() = [None; TOP_ISOLATED_LAYER_LIMIT];
739        self.top_isolated_layer_count.set(0);
740        self.shadow_shape_cache_miss_log_count.set(0);
741    }
742
743    pub fn maybe_print_snapshot(
744        &self,
745        snapshot: FrameStatsSnapshot,
746        frame_count: &mut u64,
747        enabled: bool,
748    ) {
749        if !enabled {
750            return;
751        }
752        *frame_count += 1;
753        if (*frame_count).is_multiple_of(60) {
754            snapshot.print(*frame_count);
755        }
756    }
757
758    fn record_top_isolated_layer(&self, layer: IsolatedLayerStat) {
759        if !layer.reasons.has_any() {
760            return;
761        }
762
763        let mut top_layers = self.top_isolated_layers.borrow_mut();
764        let len = self.top_isolated_layer_count.get();
765        let insert_at = top_layers[..len]
766            .iter()
767            .enumerate()
768            .find_map(|(index, existing)| {
769                existing
770                    .filter(|existing| layer.pixel_area() > existing.pixel_area())
771                    .map(|_| index)
772            })
773            .unwrap_or(len);
774
775        if insert_at >= TOP_ISOLATED_LAYER_LIMIT {
776            return;
777        }
778
779        let new_len = if len < TOP_ISOLATED_LAYER_LIMIT {
780            len + 1
781        } else {
782            TOP_ISOLATED_LAYER_LIMIT
783        };
784
785        let mut index = new_len.saturating_sub(1);
786        while index > insert_at {
787            top_layers[index] = top_layers[index - 1];
788            index -= 1;
789        }
790        top_layers[insert_at] = Some(layer);
791        self.top_isolated_layer_count.set(new_len);
792    }
793}
794
795pub(crate) fn gpu_stats_enabled() -> bool {
796    std::env::var("CRANPOSE_GPU_STATS")
797        .map(|v| matches!(v.as_str(), "1" | "true" | "yes"))
798        .unwrap_or(false)
799}
800
801/// Prints the backend allocator's block/allocation report on the same cadence
802/// as the per-frame counters.
803///
804/// The other counters measure what the renderer *asked for*; this one measures
805/// what the driver is actually holding. Vulkan and D3D12 sub-allocate every
806/// buffer and texture out of large device-memory blocks whose size comes from
807/// `wgpu::MemoryHints`, so a renderer that has asked for a couple of megabytes
808/// can still be sitting on a block tens of megabytes wide. That reserved-but-
809/// unused remainder is invisible to every other counter here and to
810/// `wgpu::Device`'s own `Counters`, but it is exactly what Android's
811/// `gpu_mem`/`dumpsys meminfo` attribute to the process — so when the two
812/// disagree, this line is the one that explains the gap.
813///
814/// `generate_allocator_report()` returns `None` on backends that do not
815/// sub-allocate through `gpu-allocator` (GL, Metal, WebGPU), where the blocks
816/// this line exists to expose do not exist either.
817pub(crate) fn print_gpu_memory_report(device: &wgpu::Device, frame_count: u64) {
818    let Some(report) = device.generate_allocator_report() else {
819        return;
820    };
821
822    const MB: f64 = 1024.0 * 1024.0;
823    let mut blocks = String::new();
824    for block in &report.blocks {
825        if !blocks.is_empty() {
826            blocks.push('+');
827        }
828        blocks.push_str(&format!("{:.1}", block.size as f64 / MB));
829    }
830
831    eprintln!(
832        "[GPU-MEM f#{}] reserved={:.1}MB allocated={:.1}MB blocks={}[{}MB] allocations={} | largest={:.6?}",
833        frame_count,
834        report.total_reserved_bytes as f64 / MB,
835        report.total_allocated_bytes as f64 / MB,
836        report.blocks.len(),
837        blocks,
838        report.allocations.len(),
839        report,
840    );
841}
842
843fn shadow_cache_diagnostics_enabled() -> bool {
844    std::env::var("CRANPOSE_GPU_SHADOW_CACHE_DIAG")
845        .map(|v| matches!(v.as_str(), "1" | "true" | "yes"))
846        .unwrap_or(false)
847}
848
849#[cfg(test)]
850mod tests {
851    use super::*;
852
853    #[test]
854    fn layer_cache_counters_accumulate_and_reset() {
855        let stats = FrameStats::default();
856        stats.record_upload_bytes(512);
857        stats.record_command_stats(FrameCommandStats {
858            encoder_count: 1,
859            submit_count: 1,
860            pass_count: 2,
861            transient_texture_bytes: 256,
862            retained_texture_bytes: 128,
863            upload_bytes: 64,
864        });
865        stats.bump_shapes();
866        stats.blur_passes.set(1);
867        stats.offscreen_total_bytes.set(1024);
868        stats.offscreen_pool_bytes.set(2048);
869        stats.record_layer_cache_hit(10, 20);
870        stats.record_layer_cache_hit(3, 4);
871        stats.record_layer_cache_miss(5, 6);
872        stats.record_layer_cache_eviction();
873        stats.record_shadow_shape_cache_hit(8, 9);
874        stats.record_shadow_shape_cache_miss(10, 11);
875        stats.record_shadow_text_blur_fallback();
876        stats.record_text_image_cache_hit(13, 17);
877        stats.record_text_image_cache_miss(19, 23);
878
879        assert_eq!(stats.layer_cache_hits.get(), 2);
880        assert_eq!(stats.layer_cache_misses.get(), 1);
881        assert_eq!(stats.layer_cache_evictions.get(), 1);
882        assert_eq!(stats.layer_cache_hit_pixels.get(), 212);
883        assert_eq!(stats.layer_cache_miss_pixels.get(), 30);
884        assert_eq!(stats.shadow_shape_cache_hits.get(), 1);
885        assert_eq!(stats.shadow_shape_cache_misses.get(), 1);
886        assert_eq!(stats.shadow_shape_cache_hit_pixels.get(), 72);
887        assert_eq!(stats.shadow_shape_cache_miss_pixels.get(), 110);
888        assert_eq!(stats.shadow_text_blur_fallbacks.get(), 1);
889
890        stats.record_isolated_layer_render(
891            7,
892            8,
893            Some(9),
894            Rect {
895                x: 2.0,
896                y: 3.0,
897                width: 4.0,
898                height: 5.0,
899            },
900            LayerSurfaceReasons {
901                text_local_surface: true,
902                ..LayerSurfaceReasons::default()
903            },
904        );
905        let snapshot = stats.snapshot();
906
907        assert_eq!(snapshot.isolated_layer_renders, 1);
908        assert_eq!(snapshot.isolated_layer_pixels, 56);
909        assert_eq!(snapshot.upload_bytes, 576);
910        assert_eq!(snapshot.encoder_count, 1);
911        assert_eq!(snapshot.submit_count, 1);
912        assert_eq!(snapshot.pass_count, 2);
913        assert_eq!(snapshot.transient_texture_bytes, 1280);
914        assert_eq!(snapshot.retained_texture_bytes, 2176);
915        assert_eq!(snapshot.layer_cache_hits, 2);
916        assert_eq!(snapshot.layer_cache_misses, 1);
917        assert_eq!(snapshot.shadow_shape_cache_hits, 1);
918        assert_eq!(snapshot.shadow_shape_cache_misses, 1);
919        assert_eq!(snapshot.shadow_shape_cache_hit_pixels, 72);
920        assert_eq!(snapshot.shadow_shape_cache_miss_pixels, 110);
921        assert_eq!(snapshot.shadow_text_blur_fallbacks, 1);
922        assert_eq!(snapshot.text_image_cache_hits, 1);
923        assert_eq!(snapshot.text_image_cache_misses, 1);
924        assert_eq!(snapshot.text_image_cache_hit_pixels, 221);
925        assert_eq!(snapshot.text_image_cache_miss_pixels, 437);
926        assert_eq!(snapshot.text_image_raster_bytes, 1748);
927        let top_layers = snapshot.top_isolated_layers().collect::<Vec<_>>();
928        assert_eq!(top_layers.len(), 1);
929        assert_eq!(top_layers[0].node_id, Some(9));
930        assert_eq!(stats.layer_cache_hits.get(), 2);
931        assert_eq!(stats.layer_cache_misses.get(), 1);
932
933        stats.reset();
934
935        assert_eq!(stats.layer_cache_hits.get(), 0);
936        assert_eq!(stats.layer_cache_misses.get(), 0);
937        assert_eq!(stats.layer_cache_evictions.get(), 0);
938        assert_eq!(stats.layer_cache_hit_pixels.get(), 0);
939        assert_eq!(stats.layer_cache_miss_pixels.get(), 0);
940        assert_eq!(stats.shadow_shape_cache_hits.get(), 0);
941        assert_eq!(stats.shadow_shape_cache_misses.get(), 0);
942        assert_eq!(stats.shadow_shape_cache_hit_pixels.get(), 0);
943        assert_eq!(stats.shadow_shape_cache_miss_pixels.get(), 0);
944        assert_eq!(stats.shadow_text_blur_fallbacks.get(), 0);
945        assert_eq!(stats.text_image_cache_hits.get(), 0);
946        assert_eq!(stats.text_image_cache_misses.get(), 0);
947        assert_eq!(stats.text_image_cache_hit_pixels.get(), 0);
948        assert_eq!(stats.text_image_cache_miss_pixels.get(), 0);
949        assert_eq!(stats.text_image_raster_bytes.get(), 0);
950        assert_eq!(stats.upload_bytes.get(), 0);
951        assert_eq!(stats.isolated_layer_renders.get(), 0);
952        assert_eq!(stats.isolated_layer_pixels.get(), 0);
953        assert_eq!(stats.top_isolated_layer_count.get(), 0);
954    }
955
956    #[test]
957    fn command_stats_accumulate_and_reset() {
958        let stats = FrameStats::default();
959
960        stats.record_command_stats(FrameCommandStats {
961            encoder_count: 2,
962            submit_count: 2,
963            pass_count: 5,
964            transient_texture_bytes: 1024,
965            retained_texture_bytes: 2048,
966            upload_bytes: 512,
967        });
968        stats.bump_shapes();
969
970        let snapshot = stats.snapshot();
971        assert_eq!(snapshot.submits, 2);
972        assert_eq!(snapshot.encoder_count, 2);
973        assert_eq!(snapshot.submit_count, 2);
974        assert_eq!(snapshot.pass_count, 5);
975        assert_eq!(snapshot.transient_texture_bytes, 1024);
976        assert_eq!(snapshot.retained_texture_bytes, 2048);
977        assert_eq!(snapshot.upload_bytes, 512);
978
979        stats.reset();
980        let reset = stats.snapshot();
981        assert_eq!(reset.submits, 0);
982        assert_eq!(reset.encoder_count, 0);
983        assert_eq!(reset.submit_count, 0);
984        assert_eq!(reset.pass_count, 0);
985        assert_eq!(reset.transient_texture_bytes, 0);
986        assert_eq!(reset.retained_texture_bytes, 0);
987        assert_eq!(reset.upload_bytes, 0);
988    }
989
990    #[test]
991    fn snapshot_adds_explicit_readback_command_stats() {
992        let stats = FrameStats::default();
993        stats.record_command_stats(FrameCommandStats {
994            encoder_count: 1,
995            submit_count: 1,
996            pass_count: 2,
997            transient_texture_bytes: 128,
998            retained_texture_bytes: 512,
999            upload_bytes: 64,
1000        });
1001        let snapshot = stats
1002            .snapshot()
1003            .with_command_stats_added(FrameCommandStats {
1004                encoder_count: 1,
1005                submit_count: 1,
1006                pass_count: 1,
1007                transient_texture_bytes: 0,
1008                retained_texture_bytes: 0,
1009                upload_bytes: 0,
1010            });
1011
1012        assert_eq!(snapshot.submits, 2);
1013        assert_eq!(snapshot.encoder_count, 2);
1014        assert_eq!(snapshot.submit_count, 2);
1015        assert_eq!(snapshot.pass_count, 3);
1016        assert_eq!(snapshot.transient_texture_bytes, 128);
1017        assert_eq!(snapshot.retained_texture_bytes, 512);
1018        assert_eq!(snapshot.upload_bytes, 64);
1019    }
1020
1021    #[test]
1022    fn maybe_print_snapshot_only_advances_frame_counter_when_enabled() {
1023        let stats = FrameStats::default();
1024        let snapshot = stats.snapshot();
1025        let mut frame_count = 0;
1026
1027        stats.maybe_print_snapshot(snapshot, &mut frame_count, false);
1028        assert_eq!(frame_count, 0);
1029
1030        stats.maybe_print_snapshot(snapshot, &mut frame_count, true);
1031        assert_eq!(frame_count, 1);
1032    }
1033
1034    #[test]
1035    fn layer_surface_reasons_report_runtime_only_bits() {
1036        let reasons = LayerSurfaceReasons {
1037            immediate_shadow: true,
1038            text_local_surface: true,
1039            mixed_direct_content: true,
1040            ..LayerSurfaceReasons::default()
1041        };
1042
1043        assert!(reasons.has_any());
1044        assert!(reasons.has_renderer_forced_surface());
1045        assert_eq!(
1046            reasons.labels().collect::<Vec<_>>(),
1047            vec![
1048                "immediate_shadow",
1049                "text_local_surface",
1050                "mixed_direct_content"
1051            ]
1052        );
1053        assert_eq!(
1054            reasons.display(),
1055            "immediate_shadow+text_local_surface+mixed_direct_content"
1056        );
1057    }
1058
1059    #[test]
1060    fn immediate_shadow_only_is_diagnostic_not_isolating() {
1061        let reasons = LayerSurfaceReasons {
1062            immediate_shadow: true,
1063            ..LayerSurfaceReasons::default()
1064        };
1065
1066        assert!(!reasons.has_any());
1067        assert!(!reasons.has_renderer_forced_surface());
1068        assert_eq!(
1069            reasons.labels().collect::<Vec<_>>(),
1070            vec!["immediate_shadow"]
1071        );
1072        assert_eq!(reasons.display(), "immediate_shadow");
1073    }
1074
1075    #[test]
1076    fn mixed_direct_content_only_is_diagnostic_not_isolating() {
1077        let reasons = LayerSurfaceReasons {
1078            mixed_direct_content: true,
1079            ..LayerSurfaceReasons::default()
1080        };
1081
1082        assert!(!reasons.has_any());
1083        assert!(!reasons.has_renderer_forced_surface());
1084        assert_eq!(
1085            reasons.labels().collect::<Vec<_>>(),
1086            vec!["mixed_direct_content"]
1087        );
1088        assert_eq!(reasons.display(), "mixed_direct_content");
1089    }
1090
1091    #[test]
1092    fn has_any_matches_has_isolating_requirement_for_each_requirement() {
1093        let all_requirements = [
1094            SurfaceRequirement::ExplicitOffscreen,
1095            SurfaceRequirement::RenderEffect,
1096            SurfaceRequirement::Backdrop,
1097            SurfaceRequirement::GroupOpacity,
1098            SurfaceRequirement::BlendMode,
1099            SurfaceRequirement::ShapeClip,
1100            SurfaceRequirement::ImmediateShadow,
1101            SurfaceRequirement::TextMaterialMask,
1102            SurfaceRequirement::MotionStableCapture,
1103            SurfaceRequirement::NonTranslationTransform,
1104            SurfaceRequirement::MixedDirectContent,
1105            SurfaceRequirement::PixelStableComposite,
1106        ];
1107        for requirement in all_requirements {
1108            let set = SurfaceRequirementSet::default().with(requirement);
1109            let reasons = LayerSurfaceReasons::from(set);
1110            assert_eq!(
1111                reasons.has_any(),
1112                set.has_isolating_requirement(),
1113                "has_any vs has_isolating_requirement mismatch for {requirement:?}"
1114            );
1115        }
1116    }
1117
1118    #[test]
1119    fn top_isolated_layers_keep_largest_runtime_surfaces() {
1120        let stats = FrameStats::default();
1121        for index in 0..(TOP_ISOLATED_LAYER_LIMIT + 2) {
1122            stats.record_isolated_layer_render(
1123                16 + index as u32,
1124                8 + index as u32,
1125                Some(index),
1126                Rect {
1127                    x: index as f32,
1128                    y: 0.0,
1129                    width: 10.0,
1130                    height: 10.0,
1131                },
1132                LayerSurfaceReasons {
1133                    text_local_surface: true,
1134                    ..LayerSurfaceReasons::default()
1135                },
1136            );
1137        }
1138
1139        let snapshot = stats.snapshot();
1140        let top_layers = snapshot.top_isolated_layers().collect::<Vec<_>>();
1141        assert_eq!(top_layers.len(), TOP_ISOLATED_LAYER_LIMIT);
1142        assert_eq!(top_layers[0].node_id, Some(TOP_ISOLATED_LAYER_LIMIT + 1));
1143        assert_eq!(top_layers[1].node_id, Some(TOP_ISOLATED_LAYER_LIMIT));
1144    }
1145}