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(&self, width: u32, height: u32, is_new: bool) {
437        self.offscreen_acquires
438            .set(self.offscreen_acquires.get() + 1);
439        if is_new {
440            self.offscreen_news.set(self.offscreen_news.get() + 1);
441        }
442        self.offscreen_total_bytes
443            .set(self.offscreen_total_bytes.get() + (width as u64) * (height as u64) * 4);
444    }
445
446    pub fn record_upload_bytes(&self, bytes: u64) {
447        self.upload_bytes
448            .set(self.upload_bytes.get().saturating_add(bytes));
449    }
450
451    pub fn record_isolated_layer_render(
452        &self,
453        width: u32,
454        height: u32,
455        node_id: Option<NodeId>,
456        logical_rect: Rect,
457        reasons: LayerSurfaceReasons,
458    ) {
459        self.isolated_layer_renders
460            .set(self.isolated_layer_renders.get().saturating_add(1));
461        self.isolated_layer_pixels.set(
462            self.isolated_layer_pixels
463                .get()
464                .saturating_add((width as u64) * (height as u64)),
465        );
466        self.record_top_isolated_layer(IsolatedLayerStat {
467            node_id,
468            logical_rect,
469            width,
470            height,
471            reasons,
472        });
473    }
474
475    pub fn record_layer_cache_hit(&self, width: u32, height: u32) {
476        self.layer_cache_hits
477            .set(self.layer_cache_hits.get().saturating_add(1));
478        self.layer_cache_hit_pixels.set(
479            self.layer_cache_hit_pixels
480                .get()
481                .saturating_add((width as u64) * (height as u64)),
482        );
483    }
484
485    pub fn record_layer_cache_miss(&self, width: u32, height: u32) {
486        self.layer_cache_misses
487            .set(self.layer_cache_misses.get().saturating_add(1));
488        self.layer_cache_miss_pixels.set(
489            self.layer_cache_miss_pixels
490                .get()
491                .saturating_add((width as u64) * (height as u64)),
492        );
493    }
494
495    pub fn record_layer_cache_eviction(&self) {
496        self.layer_cache_evictions
497            .set(self.layer_cache_evictions.get().saturating_add(1));
498    }
499
500    pub fn record_shadow_shape_cache_hit(&self, width: u32, height: u32) {
501        self.shadow_shape_cache_hits
502            .set(self.shadow_shape_cache_hits.get().saturating_add(1));
503        self.shadow_shape_cache_hit_pixels.set(
504            self.shadow_shape_cache_hit_pixels
505                .get()
506                .saturating_add((width as u64) * (height as u64)),
507        );
508    }
509
510    pub fn record_shadow_shape_cache_miss(&self, width: u32, height: u32) {
511        self.shadow_shape_cache_misses
512            .set(self.shadow_shape_cache_misses.get().saturating_add(1));
513        self.shadow_shape_cache_miss_pixels.set(
514            self.shadow_shape_cache_miss_pixels
515                .get()
516                .saturating_add((width as u64) * (height as u64)),
517        );
518    }
519
520    #[allow(clippy::too_many_arguments)]
521    pub fn maybe_print_shadow_shape_cache_miss(
522        &self,
523        width: u32,
524        height: u32,
525        content_hash: u64,
526        blur_radius: f32,
527        viewport_offset: [f32; 2],
528        shape_count: usize,
529        clip: Option<Rect>,
530    ) {
531        if !shadow_cache_diagnostics_enabled() {
532            return;
533        }
534
535        let count = self.shadow_shape_cache_miss_log_count.get();
536        if count >= 16 {
537            return;
538        }
539        self.shadow_shape_cache_miss_log_count.set(count + 1);
540
541        let clip_text = clip
542            .map(|clip| {
543                format!(
544                    "({:.1},{:.1},{:.1},{:.1})",
545                    clip.x, clip.y, clip.width, clip.height
546                )
547            })
548            .unwrap_or_else(|| "none".to_string());
549        eprintln!(
550            "[shadow-cache-miss #{count}] size={}x{} content_hash={content_hash} blur={:.2} viewport_offset=({:.1},{:.1}) shapes={} clip={}",
551            width,
552            height,
553            blur_radius,
554            viewport_offset[0],
555            viewport_offset[1],
556            shape_count,
557            clip_text,
558        );
559    }
560
561    pub fn record_shadow_text_blur_fallback(&self) {
562        self.shadow_text_blur_fallbacks
563            .set(self.shadow_text_blur_fallbacks.get().saturating_add(1));
564    }
565
566    pub fn bump_shapes(&self) {
567        self.shape_passes.set(self.shape_passes.get() + 1);
568    }
569
570    pub fn bump_images(&self) {
571        self.image_passes.set(self.image_passes.get() + 1);
572    }
573
574    /// Records `count` `draw`/`draw_indexed` calls. Call sites bump this once
575    /// per batch with the batch's draw count rather than once per draw, so the
576    /// counter costs one `Cell` update per batch rather than one per primitive.
577    pub fn add_draw_calls(&self, count: u32) {
578        self.draw_calls
579            .set(self.draw_calls.get().saturating_add(count));
580    }
581
582    pub fn bump_text(&self) {
583        self.text_passes.set(self.text_passes.get() + 1);
584    }
585
586    pub fn record_text_image_cache_hit(&self, width: u32, height: u32) {
587        self.text_image_cache_hits
588            .set(self.text_image_cache_hits.get().saturating_add(1));
589        self.text_image_cache_hit_pixels.set(
590            self.text_image_cache_hit_pixels
591                .get()
592                .saturating_add((width as u64) * (height as u64)),
593        );
594    }
595
596    pub fn record_text_image_cache_miss(&self, width: u32, height: u32) {
597        let pixels = (width as u64) * (height as u64);
598        self.text_image_cache_misses
599            .set(self.text_image_cache_misses.get().saturating_add(1));
600        self.text_image_cache_miss_pixels.set(
601            self.text_image_cache_miss_pixels
602                .get()
603                .saturating_add(pixels),
604        );
605        self.text_image_raster_bytes.set(
606            self.text_image_raster_bytes
607                .get()
608                .saturating_add(pixels * 4),
609        );
610    }
611
612    pub fn record_text_glyph_atlas_hit(&self) {
613        self.text_glyph_atlas_hits
614            .set(self.text_glyph_atlas_hits.get().saturating_add(1));
615    }
616
617    pub fn record_text_glyph_atlas_miss(&self, width: u32, height: u32) {
618        self.text_glyph_atlas_misses
619            .set(self.text_glyph_atlas_misses.get().saturating_add(1));
620        self.text_glyph_atlas_miss_pixels.set(
621            self.text_glyph_atlas_miss_pixels
622                .get()
623                .saturating_add((width as u64) * (height as u64)),
624        );
625    }
626
627    pub fn snapshot(&self) -> FrameStatsSnapshot {
628        let retained_texture_bytes = self
629            .offscreen_pool_bytes
630            .get()
631            .saturating_add(self.layer_cache_bytes.get());
632        FrameStatsSnapshot {
633            submits: self.submits.get(),
634            encoder_count: self.command_encoder_count.get(),
635            submit_count: self.command_submit_count.get(),
636            pass_count: self.command_pass_count.get(),
637            offscreen_acquires: self.offscreen_acquires.get(),
638            offscreen_news: self.offscreen_news.get(),
639            offscreen_total_bytes: self.offscreen_total_bytes.get(),
640            transient_texture_bytes: self
641                .offscreen_total_bytes
642                .get()
643                .saturating_add(self.command_transient_texture_bytes.get()),
644            retained_texture_bytes: retained_texture_bytes
645                .saturating_add(self.command_retained_texture_bytes.get()),
646            upload_bytes: self
647                .upload_bytes
648                .get()
649                .saturating_add(self.command_upload_bytes.get()),
650            isolated_layer_renders: self.isolated_layer_renders.get(),
651            isolated_layer_pixels: self.isolated_layer_pixels.get(),
652            layer_cache_hits: self.layer_cache_hits.get(),
653            layer_cache_misses: self.layer_cache_misses.get(),
654            layer_cache_evictions: self.layer_cache_evictions.get(),
655            layer_cache_hit_pixels: self.layer_cache_hit_pixels.get(),
656            layer_cache_miss_pixels: self.layer_cache_miss_pixels.get(),
657            shadow_shape_cache_hits: self.shadow_shape_cache_hits.get(),
658            shadow_shape_cache_misses: self.shadow_shape_cache_misses.get(),
659            shadow_shape_cache_hit_pixels: self.shadow_shape_cache_hit_pixels.get(),
660            shadow_shape_cache_miss_pixels: self.shadow_shape_cache_miss_pixels.get(),
661            shadow_text_blur_fallbacks: self.shadow_text_blur_fallbacks.get(),
662            blur_passes: self.blur_passes.get(),
663            composite_passes: self.composite_passes.get(),
664            effect_applies: self.effect_applies.get(),
665            shape_passes: self.shape_passes.get(),
666            image_passes: self.image_passes.get(),
667            text_passes: self.text_passes.get(),
668            draw_calls: self.draw_calls.get(),
669            text_image_cache_hits: self.text_image_cache_hits.get(),
670            text_image_cache_misses: self.text_image_cache_misses.get(),
671            text_image_cache_hit_pixels: self.text_image_cache_hit_pixels.get(),
672            text_image_cache_miss_pixels: self.text_image_cache_miss_pixels.get(),
673            text_image_raster_bytes: self.text_image_raster_bytes.get(),
674            text_glyph_atlas_hits: self.text_glyph_atlas_hits.get(),
675            text_glyph_atlas_misses: self.text_glyph_atlas_misses.get(),
676            text_glyph_atlas_miss_pixels: self.text_glyph_atlas_miss_pixels.get(),
677            offscreen_pool_size: self.offscreen_pool_size.get(),
678            offscreen_pool_bytes: self.offscreen_pool_bytes.get(),
679            text_pool_size: self.text_pool_size.get(),
680            layer_cache_size: self.layer_cache_size.get(),
681            layer_cache_bytes: self.layer_cache_bytes.get(),
682            image_cache_size: self.image_cache_size.get(),
683            text_cache_size: self.text_cache_size.get(),
684            top_isolated_layers: *self.top_isolated_layers.borrow(),
685            top_isolated_layer_count: self.top_isolated_layer_count.get(),
686        }
687    }
688
689    pub fn reset(&self) {
690        self.submits.set(0);
691        self.command_encoder_count.set(0);
692        self.command_submit_count.set(0);
693        self.command_pass_count.set(0);
694        self.command_transient_texture_bytes.set(0);
695        self.command_retained_texture_bytes.set(0);
696        self.command_upload_bytes.set(0);
697        self.offscreen_acquires.set(0);
698        self.offscreen_news.set(0);
699        self.offscreen_total_bytes.set(0);
700        self.upload_bytes.set(0);
701        self.isolated_layer_renders.set(0);
702        self.isolated_layer_pixels.set(0);
703        self.layer_cache_hits.set(0);
704        self.layer_cache_misses.set(0);
705        self.layer_cache_evictions.set(0);
706        self.layer_cache_hit_pixels.set(0);
707        self.layer_cache_miss_pixels.set(0);
708        self.shadow_shape_cache_hits.set(0);
709        self.shadow_shape_cache_misses.set(0);
710        self.shadow_shape_cache_hit_pixels.set(0);
711        self.shadow_shape_cache_miss_pixels.set(0);
712        self.shadow_text_blur_fallbacks.set(0);
713        self.blur_passes.set(0);
714        self.composite_passes.set(0);
715        self.effect_applies.set(0);
716        self.shape_passes.set(0);
717        self.image_passes.set(0);
718        self.text_passes.set(0);
719        self.draw_calls.set(0);
720        self.text_image_cache_hits.set(0);
721        self.text_image_cache_misses.set(0);
722        self.text_image_cache_hit_pixels.set(0);
723        self.text_image_cache_miss_pixels.set(0);
724        self.text_image_raster_bytes.set(0);
725        self.text_glyph_atlas_hits.set(0);
726        self.text_glyph_atlas_misses.set(0);
727        self.text_glyph_atlas_miss_pixels.set(0);
728        *self.top_isolated_layers.borrow_mut() = [None; TOP_ISOLATED_LAYER_LIMIT];
729        self.top_isolated_layer_count.set(0);
730        self.shadow_shape_cache_miss_log_count.set(0);
731    }
732
733    pub fn maybe_print_snapshot(
734        &self,
735        snapshot: FrameStatsSnapshot,
736        frame_count: &mut u64,
737        enabled: bool,
738    ) {
739        if !enabled {
740            return;
741        }
742        *frame_count += 1;
743        if (*frame_count).is_multiple_of(60) {
744            snapshot.print(*frame_count);
745        }
746    }
747
748    fn record_top_isolated_layer(&self, layer: IsolatedLayerStat) {
749        if !layer.reasons.has_any() {
750            return;
751        }
752
753        let mut top_layers = self.top_isolated_layers.borrow_mut();
754        let len = self.top_isolated_layer_count.get();
755        let insert_at = top_layers[..len]
756            .iter()
757            .enumerate()
758            .find_map(|(index, existing)| {
759                existing
760                    .filter(|existing| layer.pixel_area() > existing.pixel_area())
761                    .map(|_| index)
762            })
763            .unwrap_or(len);
764
765        if insert_at >= TOP_ISOLATED_LAYER_LIMIT {
766            return;
767        }
768
769        let new_len = if len < TOP_ISOLATED_LAYER_LIMIT {
770            len + 1
771        } else {
772            TOP_ISOLATED_LAYER_LIMIT
773        };
774
775        let mut index = new_len.saturating_sub(1);
776        while index > insert_at {
777            top_layers[index] = top_layers[index - 1];
778            index -= 1;
779        }
780        top_layers[insert_at] = Some(layer);
781        self.top_isolated_layer_count.set(new_len);
782    }
783}
784
785pub(crate) fn gpu_stats_enabled() -> bool {
786    std::env::var("CRANPOSE_GPU_STATS")
787        .map(|v| matches!(v.as_str(), "1" | "true" | "yes"))
788        .unwrap_or(false)
789}
790
791/// Prints the backend allocator's block/allocation report on the same cadence
792/// as the per-frame counters.
793///
794/// The other counters measure what the renderer *asked for*; this one measures
795/// what the driver is actually holding. Vulkan and D3D12 sub-allocate every
796/// buffer and texture out of large device-memory blocks whose size comes from
797/// `wgpu::MemoryHints`, so a renderer that has asked for a couple of megabytes
798/// can still be sitting on a block tens of megabytes wide. That reserved-but-
799/// unused remainder is invisible to every other counter here and to
800/// `wgpu::Device`'s own `Counters`, but it is exactly what Android's
801/// `gpu_mem`/`dumpsys meminfo` attribute to the process — so when the two
802/// disagree, this line is the one that explains the gap.
803///
804/// `generate_allocator_report()` returns `None` on backends that do not
805/// sub-allocate through `gpu-allocator` (GL, Metal, WebGPU), where the blocks
806/// this line exists to expose do not exist either.
807pub(crate) fn print_gpu_memory_report(device: &wgpu::Device, frame_count: u64) {
808    let Some(report) = device.generate_allocator_report() else {
809        return;
810    };
811
812    const MB: f64 = 1024.0 * 1024.0;
813    let mut blocks = String::new();
814    for block in &report.blocks {
815        if !blocks.is_empty() {
816            blocks.push('+');
817        }
818        blocks.push_str(&format!("{:.1}", block.size as f64 / MB));
819    }
820
821    eprintln!(
822        "[GPU-MEM f#{}] reserved={:.1}MB allocated={:.1}MB blocks={}[{}MB] allocations={} | largest={:.6?}",
823        frame_count,
824        report.total_reserved_bytes as f64 / MB,
825        report.total_allocated_bytes as f64 / MB,
826        report.blocks.len(),
827        blocks,
828        report.allocations.len(),
829        report,
830    );
831}
832
833fn shadow_cache_diagnostics_enabled() -> bool {
834    std::env::var("CRANPOSE_GPU_SHADOW_CACHE_DIAG")
835        .map(|v| matches!(v.as_str(), "1" | "true" | "yes"))
836        .unwrap_or(false)
837}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842
843    #[test]
844    fn layer_cache_counters_accumulate_and_reset() {
845        let stats = FrameStats::default();
846        stats.record_upload_bytes(512);
847        stats.record_command_stats(FrameCommandStats {
848            encoder_count: 1,
849            submit_count: 1,
850            pass_count: 2,
851            transient_texture_bytes: 256,
852            retained_texture_bytes: 128,
853            upload_bytes: 64,
854        });
855        stats.bump_shapes();
856        stats.blur_passes.set(1);
857        stats.offscreen_total_bytes.set(1024);
858        stats.offscreen_pool_bytes.set(2048);
859        stats.record_layer_cache_hit(10, 20);
860        stats.record_layer_cache_hit(3, 4);
861        stats.record_layer_cache_miss(5, 6);
862        stats.record_layer_cache_eviction();
863        stats.record_shadow_shape_cache_hit(8, 9);
864        stats.record_shadow_shape_cache_miss(10, 11);
865        stats.record_shadow_text_blur_fallback();
866        stats.record_text_image_cache_hit(13, 17);
867        stats.record_text_image_cache_miss(19, 23);
868
869        assert_eq!(stats.layer_cache_hits.get(), 2);
870        assert_eq!(stats.layer_cache_misses.get(), 1);
871        assert_eq!(stats.layer_cache_evictions.get(), 1);
872        assert_eq!(stats.layer_cache_hit_pixels.get(), 212);
873        assert_eq!(stats.layer_cache_miss_pixels.get(), 30);
874        assert_eq!(stats.shadow_shape_cache_hits.get(), 1);
875        assert_eq!(stats.shadow_shape_cache_misses.get(), 1);
876        assert_eq!(stats.shadow_shape_cache_hit_pixels.get(), 72);
877        assert_eq!(stats.shadow_shape_cache_miss_pixels.get(), 110);
878        assert_eq!(stats.shadow_text_blur_fallbacks.get(), 1);
879
880        stats.record_isolated_layer_render(
881            7,
882            8,
883            Some(9),
884            Rect {
885                x: 2.0,
886                y: 3.0,
887                width: 4.0,
888                height: 5.0,
889            },
890            LayerSurfaceReasons {
891                text_local_surface: true,
892                ..LayerSurfaceReasons::default()
893            },
894        );
895        let snapshot = stats.snapshot();
896
897        assert_eq!(snapshot.isolated_layer_renders, 1);
898        assert_eq!(snapshot.isolated_layer_pixels, 56);
899        assert_eq!(snapshot.upload_bytes, 576);
900        assert_eq!(snapshot.encoder_count, 1);
901        assert_eq!(snapshot.submit_count, 1);
902        assert_eq!(snapshot.pass_count, 2);
903        assert_eq!(snapshot.transient_texture_bytes, 1280);
904        assert_eq!(snapshot.retained_texture_bytes, 2176);
905        assert_eq!(snapshot.layer_cache_hits, 2);
906        assert_eq!(snapshot.layer_cache_misses, 1);
907        assert_eq!(snapshot.shadow_shape_cache_hits, 1);
908        assert_eq!(snapshot.shadow_shape_cache_misses, 1);
909        assert_eq!(snapshot.shadow_shape_cache_hit_pixels, 72);
910        assert_eq!(snapshot.shadow_shape_cache_miss_pixels, 110);
911        assert_eq!(snapshot.shadow_text_blur_fallbacks, 1);
912        assert_eq!(snapshot.text_image_cache_hits, 1);
913        assert_eq!(snapshot.text_image_cache_misses, 1);
914        assert_eq!(snapshot.text_image_cache_hit_pixels, 221);
915        assert_eq!(snapshot.text_image_cache_miss_pixels, 437);
916        assert_eq!(snapshot.text_image_raster_bytes, 1748);
917        let top_layers = snapshot.top_isolated_layers().collect::<Vec<_>>();
918        assert_eq!(top_layers.len(), 1);
919        assert_eq!(top_layers[0].node_id, Some(9));
920        assert_eq!(stats.layer_cache_hits.get(), 2);
921        assert_eq!(stats.layer_cache_misses.get(), 1);
922
923        stats.reset();
924
925        assert_eq!(stats.layer_cache_hits.get(), 0);
926        assert_eq!(stats.layer_cache_misses.get(), 0);
927        assert_eq!(stats.layer_cache_evictions.get(), 0);
928        assert_eq!(stats.layer_cache_hit_pixels.get(), 0);
929        assert_eq!(stats.layer_cache_miss_pixels.get(), 0);
930        assert_eq!(stats.shadow_shape_cache_hits.get(), 0);
931        assert_eq!(stats.shadow_shape_cache_misses.get(), 0);
932        assert_eq!(stats.shadow_shape_cache_hit_pixels.get(), 0);
933        assert_eq!(stats.shadow_shape_cache_miss_pixels.get(), 0);
934        assert_eq!(stats.shadow_text_blur_fallbacks.get(), 0);
935        assert_eq!(stats.text_image_cache_hits.get(), 0);
936        assert_eq!(stats.text_image_cache_misses.get(), 0);
937        assert_eq!(stats.text_image_cache_hit_pixels.get(), 0);
938        assert_eq!(stats.text_image_cache_miss_pixels.get(), 0);
939        assert_eq!(stats.text_image_raster_bytes.get(), 0);
940        assert_eq!(stats.upload_bytes.get(), 0);
941        assert_eq!(stats.isolated_layer_renders.get(), 0);
942        assert_eq!(stats.isolated_layer_pixels.get(), 0);
943        assert_eq!(stats.top_isolated_layer_count.get(), 0);
944    }
945
946    #[test]
947    fn command_stats_accumulate_and_reset() {
948        let stats = FrameStats::default();
949
950        stats.record_command_stats(FrameCommandStats {
951            encoder_count: 2,
952            submit_count: 2,
953            pass_count: 5,
954            transient_texture_bytes: 1024,
955            retained_texture_bytes: 2048,
956            upload_bytes: 512,
957        });
958        stats.bump_shapes();
959
960        let snapshot = stats.snapshot();
961        assert_eq!(snapshot.submits, 2);
962        assert_eq!(snapshot.encoder_count, 2);
963        assert_eq!(snapshot.submit_count, 2);
964        assert_eq!(snapshot.pass_count, 5);
965        assert_eq!(snapshot.transient_texture_bytes, 1024);
966        assert_eq!(snapshot.retained_texture_bytes, 2048);
967        assert_eq!(snapshot.upload_bytes, 512);
968
969        stats.reset();
970        let reset = stats.snapshot();
971        assert_eq!(reset.submits, 0);
972        assert_eq!(reset.encoder_count, 0);
973        assert_eq!(reset.submit_count, 0);
974        assert_eq!(reset.pass_count, 0);
975        assert_eq!(reset.transient_texture_bytes, 0);
976        assert_eq!(reset.retained_texture_bytes, 0);
977        assert_eq!(reset.upload_bytes, 0);
978    }
979
980    #[test]
981    fn snapshot_adds_explicit_readback_command_stats() {
982        let stats = FrameStats::default();
983        stats.record_command_stats(FrameCommandStats {
984            encoder_count: 1,
985            submit_count: 1,
986            pass_count: 2,
987            transient_texture_bytes: 128,
988            retained_texture_bytes: 512,
989            upload_bytes: 64,
990        });
991        let snapshot = stats
992            .snapshot()
993            .with_command_stats_added(FrameCommandStats {
994                encoder_count: 1,
995                submit_count: 1,
996                pass_count: 1,
997                transient_texture_bytes: 0,
998                retained_texture_bytes: 0,
999                upload_bytes: 0,
1000            });
1001
1002        assert_eq!(snapshot.submits, 2);
1003        assert_eq!(snapshot.encoder_count, 2);
1004        assert_eq!(snapshot.submit_count, 2);
1005        assert_eq!(snapshot.pass_count, 3);
1006        assert_eq!(snapshot.transient_texture_bytes, 128);
1007        assert_eq!(snapshot.retained_texture_bytes, 512);
1008        assert_eq!(snapshot.upload_bytes, 64);
1009    }
1010
1011    #[test]
1012    fn maybe_print_snapshot_only_advances_frame_counter_when_enabled() {
1013        let stats = FrameStats::default();
1014        let snapshot = stats.snapshot();
1015        let mut frame_count = 0;
1016
1017        stats.maybe_print_snapshot(snapshot, &mut frame_count, false);
1018        assert_eq!(frame_count, 0);
1019
1020        stats.maybe_print_snapshot(snapshot, &mut frame_count, true);
1021        assert_eq!(frame_count, 1);
1022    }
1023
1024    #[test]
1025    fn layer_surface_reasons_report_runtime_only_bits() {
1026        let reasons = LayerSurfaceReasons {
1027            immediate_shadow: true,
1028            text_local_surface: true,
1029            mixed_direct_content: true,
1030            ..LayerSurfaceReasons::default()
1031        };
1032
1033        assert!(reasons.has_any());
1034        assert!(reasons.has_renderer_forced_surface());
1035        assert_eq!(
1036            reasons.labels().collect::<Vec<_>>(),
1037            vec![
1038                "immediate_shadow",
1039                "text_local_surface",
1040                "mixed_direct_content"
1041            ]
1042        );
1043        assert_eq!(
1044            reasons.display(),
1045            "immediate_shadow+text_local_surface+mixed_direct_content"
1046        );
1047    }
1048
1049    #[test]
1050    fn immediate_shadow_only_is_diagnostic_not_isolating() {
1051        let reasons = LayerSurfaceReasons {
1052            immediate_shadow: true,
1053            ..LayerSurfaceReasons::default()
1054        };
1055
1056        assert!(!reasons.has_any());
1057        assert!(!reasons.has_renderer_forced_surface());
1058        assert_eq!(
1059            reasons.labels().collect::<Vec<_>>(),
1060            vec!["immediate_shadow"]
1061        );
1062        assert_eq!(reasons.display(), "immediate_shadow");
1063    }
1064
1065    #[test]
1066    fn mixed_direct_content_only_is_diagnostic_not_isolating() {
1067        let reasons = LayerSurfaceReasons {
1068            mixed_direct_content: true,
1069            ..LayerSurfaceReasons::default()
1070        };
1071
1072        assert!(!reasons.has_any());
1073        assert!(!reasons.has_renderer_forced_surface());
1074        assert_eq!(
1075            reasons.labels().collect::<Vec<_>>(),
1076            vec!["mixed_direct_content"]
1077        );
1078        assert_eq!(reasons.display(), "mixed_direct_content");
1079    }
1080
1081    #[test]
1082    fn has_any_matches_has_isolating_requirement_for_each_requirement() {
1083        let all_requirements = [
1084            SurfaceRequirement::ExplicitOffscreen,
1085            SurfaceRequirement::RenderEffect,
1086            SurfaceRequirement::Backdrop,
1087            SurfaceRequirement::GroupOpacity,
1088            SurfaceRequirement::BlendMode,
1089            SurfaceRequirement::ShapeClip,
1090            SurfaceRequirement::ImmediateShadow,
1091            SurfaceRequirement::TextMaterialMask,
1092            SurfaceRequirement::MotionStableCapture,
1093            SurfaceRequirement::NonTranslationTransform,
1094            SurfaceRequirement::MixedDirectContent,
1095            SurfaceRequirement::PixelStableComposite,
1096        ];
1097        for requirement in all_requirements {
1098            let set = SurfaceRequirementSet::default().with(requirement);
1099            let reasons = LayerSurfaceReasons::from(set);
1100            assert_eq!(
1101                reasons.has_any(),
1102                set.has_isolating_requirement(),
1103                "has_any vs has_isolating_requirement mismatch for {requirement:?}"
1104            );
1105        }
1106    }
1107
1108    #[test]
1109    fn top_isolated_layers_keep_largest_runtime_surfaces() {
1110        let stats = FrameStats::default();
1111        for index in 0..(TOP_ISOLATED_LAYER_LIMIT + 2) {
1112            stats.record_isolated_layer_render(
1113                16 + index as u32,
1114                8 + index as u32,
1115                Some(index),
1116                Rect {
1117                    x: index as f32,
1118                    y: 0.0,
1119                    width: 10.0,
1120                    height: 10.0,
1121                },
1122                LayerSurfaceReasons {
1123                    text_local_surface: true,
1124                    ..LayerSurfaceReasons::default()
1125                },
1126            );
1127        }
1128
1129        let snapshot = stats.snapshot();
1130        let top_layers = snapshot.top_isolated_layers().collect::<Vec<_>>();
1131        assert_eq!(top_layers.len(), TOP_ISOLATED_LAYER_LIMIT);
1132        assert_eq!(top_layers[0].node_id, Some(TOP_ISOLATED_LAYER_LIMIT + 1));
1133        assert_eq!(top_layers[1].node_id, Some(TOP_ISOLATED_LAYER_LIMIT));
1134    }
1135}