tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1 llimphi-ui/src/eventloop/perf.rs Rust · 565 líneas · 24.3 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · Sergio ·

1// eventloop/perf.rs — Bloque 21 (PLAN-NUCLEO-RETENIDO.md): perfilador por frame
2// del camino COMPLETO de redraw (view → mount → layout → paint → raster → gpu →
3// present). Off por defecto. Se enciende con `LLIMPHI_PERF=1` (o `=N` para
4// reportar cada N frames; default 60). Cero costo apagado: una sola lectura de
5// env cacheada, y ni siquiera se toman timestamps.
6//
7// El camino `cache_hit` (retención de frame entero) NO se muestrea a propósito:
8// este contador mide sólo el trabajo que los Bloques 22–25 buscan reducir, así
9// que la regla del repo (§8: certificar con números, no screenshots) tiene una
10// base honesta de antes/después.
11
12use std::cell::RefCell;
13use std::sync::OnceLock;
14use crate::Instant;
15
16/// Intervalo de reporte en frames. `None` = perfilador apagado. Cacheado: una
17/// sola lectura de env por proceso.
18fn interval() -> Option<u64> {
19    static CELL: OnceLock<Option<u64>> = OnceLock::new();
20    *CELL.get_or_init(|| match std::env::var("LLIMPHI_PERF") {
21        // El valor es el intervalo de reporte en frames. `1`/no-numérico ⇒ 60
22        // (default): así `LLIMPHI_PERF=1` significa "encendido, reporta cada 60"
23        // y no floodea una línea por frame. `=120` = cada 120; `=0` = apagado.
24        Ok(v) if !v.is_empty() && v != "0" => {
25            Some(v.parse::<u64>().ok().filter(|&n| n >= 2).unwrap_or(60))
26        }
27        _ => None,
28    })
29}
30
31/// `true` si el perfilador está activo.
32#[inline]
33pub(crate) fn enabled() -> bool {
34    interval().is_some()
35}
36
37/// Cada cuántos cuadros reportar **dónde poner un `repaint_boundary`**
38/// (`LLIMPHI_BOUNDARY_HINT`). `None` = apagado, que es el default.
39///
40/// Va aparte de `LLIMPHI_PERF` a propósito: el perfilador es barato y se deja
41/// puesto, éste hashea cada subárbol candidato por cuadro y se enciende un rato
42/// para averiguar algo. Mezclarlos habría hecho que `LLIMPHI_PERF=1` dejara de
43/// medir el frame y pasara a medirse a sí mismo.
44pub(crate) fn boundary_hint() -> Option<u64> {
45    static CELL: OnceLock<Option<u64>> = OnceLock::new();
46    *CELL.get_or_init(|| match std::env::var("LLIMPHI_BOUNDARY_HINT") {
47        Ok(v) if !v.is_empty() && v != "0" => {
48            Some(v.parse::<u64>().ok().filter(|&n| n >= 2).unwrap_or(120))
49        }
50        _ => None,
51    })
52}
53
54thread_local! {
55    /// Cuadros vistos por el detector desde el último reporte.
56    static CUADROS_HINT: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
57}
58
59/// Imprime las sugerencias cada `boundary_hint()` cuadros. Silencio absoluto si
60/// no hay ninguna: un panel que sí cambia no tiene por qué generar ruido.
61pub(crate) fn reportar_boundaries(sugerencias: &[llimphi_compositor::SugerenciaBoundary]) {
62    let Some(cada) = boundary_hint() else { return };
63    let n = CUADROS_HINT.with(|c| {
64        let v = c.get() + 1;
65        c.set(if v >= cada { 0 } else { v });
66        v
67    });
68    if n < cada || sugerencias.is_empty() {
69        return;
70    }
71    let nodos: usize = sugerencias.iter().map(|s| s.nodos).sum();
72    println!(
73        "[llimphi] {} subárbol(es) quieto(s), {nodos} nodos que se repintan de gusto \
74         — poneles .repaint_boundary(clave):",
75        sugerencias.len()
76    );
77    for s in sugerencias {
78        println!("{}", llimphi_compositor::describir(s));
79    }
80}
81
82/// Toma un timestamp sólo si el perfilador está activo (`on`). Cuando está
83/// apagado devuelve `None` y no toca el reloj.
84#[inline]
85pub(crate) fn mark(on: bool) -> Option<Instant> {
86    if on {
87        Some(Instant::now())
88    } else {
89        None
90    }
91}
92
93/// Microsegundos entre dos marcas. `0.0` si alguna es `None` (perfilador off).
94#[inline]
95pub(crate) fn us(a: Option<Instant>, b: Option<Instant>) -> f64 {
96    match (a, b) {
97        (Some(a), Some(b)) => b.saturating_duration_since(a).as_secs_f64() * 1e6,
98        _ => 0.0,
99    }
100}
101
102/// Lectura del contador de asignaciones (`crate::alloc`) en un punto del frame.
103#[derive(Clone, Copy, Default)]
104pub(crate) struct AllocMark {
105    asignaciones: u64,
106    bytes: u64,
107}
108
109/// Toma una lectura del contador de heap sólo si el perfilador está activo.
110/// Cuando está apagado no toca ni los atómicos.
111#[inline]
112pub(crate) fn alloc_mark(on: bool) -> Option<AllocMark> {
113    if on {
114        let (asignaciones, bytes, _libres) = crate::alloc::snapshot();
115        Some(AllocMark {
116            asignaciones,
117            bytes,
118        })
119    } else {
120        None
121    }
122}
123
124/// Asignaciones entre dos lecturas. `0` si alguna es `None` (perfilador off) o
125/// si nadie instaló el contador (entonces ambas leen el mismo cero).
126#[inline]
127pub(crate) fn allocs(a: Option<AllocMark>, b: Option<AllocMark>) -> u64 {
128    match (a, b) {
129        (Some(a), Some(b)) => b.asignaciones.saturating_sub(a.asignaciones),
130        _ => 0,
131    }
132}
133
134/// Bytes pedidos al heap entre dos lecturas (crecimiento neto en los realloc).
135#[inline]
136pub(crate) fn alloc_bytes(a: Option<AllocMark>, b: Option<AllocMark>) -> u64 {
137    match (a, b) {
138        (Some(a), Some(b)) => b.bytes.saturating_sub(a.bytes),
139        _ => 0,
140    }
141}
142
143/// Una muestra de frame: microsegundos por etapa + conteo de nodos.
144#[derive(Clone, Copy, Default)]
145pub(crate) struct FrameSample {
146    pub view_us: f64,
147    pub mount_us: f64,
148    pub layout_us: f64,
149    /// Incluye reconciles (anim/hero/size), mount+layout del overlay, hit-tests
150    /// y el paint de la escena principal (fills/text/ghosts/ripple/overlay).
151    pub paint_us: f64,
152    /// Rasterización vello de la escena principal (`Renderer::render`).
153    pub raster_us: f64,
154    /// Pasadas GPU directas + backdrop/filter + composites + submit.
155    pub gpu_us: f64,
156    /// Desglose del tramo `gpu` para la capa `over` (subárboles `View::over`),
157    /// que es el camino caro cuando una app tiene fondo/OSC encima del canvas:
158    /// crear la scratch del tamaño del frame · rasterizarla con vello · el
159    /// `submit` final. Se reportan sólo si alguno es no-cero.
160    /// Construir la escena vello de la capa over: corre los `paint_with` de
161    /// la app (p. ej. el fondo animado de media). Puro CPU.
162    pub over_paint_us: f64,
163    pub over_tex_us: f64,
164    pub over_raster_us: f64,
165    /// `true` si el cuadro **reusó** la capa over ya dibujada (misma huella de
166    /// escena) en vez de rasterizarla. En régimen —video corriendo, barra de
167    /// controles quieta— tiene que ser la abrumadora mayoría; si el reporte
168    /// muestra pocos aciertos con la UI quieta, algo está latiendo de más.
169    pub over_cache_hit: bool,
170    /// `true` si el cuadro se salteó entero (nada cambió: se presentó la
171    /// intermedia tal como quedó). Ver `App::gpu_content_changed`.
172    pub frame_salteado: bool,
173    pub submit_us: f64,
174    pub present_us: f64,
175    /// Lo que tardó `Surface::acquire()` — o sea **la espera del vsync**. En
176    /// `Fifo`/`AutoVsync` esa llamada BLOQUEA hasta que el compositor libera un
177    /// buffer, así que acá es donde se ve la contrapresión de mirada.
178    ///
179    /// Existe porque hasta el 2026-08-25 el `acquire` caía **fuera** del
180    /// perfilador (se hace antes de `m_start`), y ése era el agujero exacto por
181    /// el que se colaba el diagnóstico de media: `total` daba 5 ms mientras el
182    /// reproductor sostenía 10–18 cuadros por segundo, y el tiempo que faltaba
183    /// no aparecía en ninguna etapa. «El 96 % es espera de vsync» era una
184    /// conjetura sin contador; ahora es una columna.
185    pub acquire_us: f64,
186    /// Desde que terminó el cuadro anterior hasta que empezó éste: el rato en
187    /// que el bucle **no estaba corriendo**. Con `acquire_us` cierra el reloj de
188    /// pared — `entre + acquire + total` es el período real del bucle, y ahí se
189    /// lee si la app trabaja de más, si la frena el compositor, o si
190    /// directamente nadie le pidió que repintara.
191    pub entre_us: f64,
192    /// La parte de [`Self::entre_us`] que el bucle pasó **trabajando en otra
193    /// cosa**: eventos de ventana que no son el repintado (puntero, teclas,
194    /// resize), `Msg` del canal (`Handle::dispatch`, los ticks periódicos) y el
195    /// `about_to_wait`. El resto de `entre` es ocio real — dormido esperando
196    /// que el compositor pida el cuadro siguiente.
197    ///
198    /// Existe porque `entre` solo no alcanza para decidir nada: 40 ms de hueco
199    /// se leen igual si la app estuvo quieta esperando a mirada que si estuvo
200    /// moliendo hit-tests por cada movimiento del puntero, y el arreglo de cada
201    /// caso está en un crate distinto. Medido el 26-ago en media: `entre` era el
202    /// 80 % del período y nadie sabía de cuál de los dos se trataba.
203    pub otros_us: f64,
204    pub total_us: f64,
205    pub n_nodes: usize,
206    /// Aciertos/fallos ACUMULADOS del caché de RepaintBoundary (Bloque 23) al
207    /// cierre del frame. El reporte muestra el delta del lote (hits/misses en
208    /// esta ventana) — sólo si hubo actividad de boundary.
209    pub boundary_hits: u64,
210    pub boundary_misses: u64,
211    /// `true` si este frame reusó el layout retenido (Bloque 24) en vez de
212    /// hacer `clear`+rebuild. El reporte muestra cuántos frames del lote lo
213    /// hicieron.
214    pub layout_reused: bool,
215    /// `true` si el mount reusó los buffers del frame anterior (Bloque 21,
216    /// palanca 1). Se reporta aparte de los bytes porque "la optimización no
217    /// ayudó" y "la optimización no corrió" se ven igual mirando sólo los bytes
218    /// — y la primera versión de esto no corría nunca.
219    pub mount_recycled: bool,
220    /// `true` si el layout escribió encima del `ComputedLayout` del frame
221    /// anterior en vez de construir uno nuevo. Se reporta por el mismo motivo
222    /// que `mount_recycled`, y la lección se pagó una vez: sin este campo, un
223    /// −0 % en los bytes no se distingue de que el código no corrió.
224    pub layout_recycled: bool,
225    /// Asignaciones de heap del frame completo y desglose por las mismas etapas
226    /// que los µs (`view` incluye el `view()` de la app + los builders diferidos
227    /// + el size-anim; `paint` incluye reconciles, overlay, hit-tests y la
228    /// construcción de la `Scene`). Todo cero si nadie instaló
229    /// [`crate::alloc::ContadorAlloc`] — el reporte lo distingue con `n/a`.
230    pub allocs: u64,
231    pub allocs_view: u64,
232    pub allocs_mount: u64,
233    pub allocs_layout: u64,
234    pub allocs_paint: u64,
235    /// Bytes pedidos al heap en el frame (crecimiento neto en los `realloc`) y
236    /// el mismo desglose por etapa. Sin bytes por etapa no se puede saber si las
237    /// pocas asignaciones de una etapa son grandes o chicas — y esa es
238    /// justamente la pregunta que decide qué se ataca primero.
239    pub alloc_bytes: u64,
240    pub alloc_bytes_view: u64,
241    pub alloc_bytes_mount: u64,
242    pub alloc_bytes_layout: u64,
243    pub alloc_bytes_paint: u64,
244}
245
246thread_local! {
247    static BUF: RefCell<Vec<FrameSample>> = const { RefCell::new(Vec::new()) };
248}
249
250thread_local! {
251    /// Cuándo terminó el último cuadro (después del `present`). De acá sale
252    /// [`hueco`]. `None` = todavía no hubo ninguno, y el primer cuadro no
253    /// inventa un hueco.
254    static FIN_PREVIO: std::cell::Cell<Option<Instant>> =
255        const { std::cell::Cell::new(None) };
256}
257
258/// Microsegundos desde el fin del cuadro anterior. `0.0` con el perfilador
259/// apagado o en el primer cuadro.
260#[inline]
261pub(crate) fn hueco(on: bool) -> f64 {
262    if !on {
263        return 0.0;
264    }
265    FIN_PREVIO.with(|c| {
266        c.get()
267            .map(|t| Instant::now().saturating_duration_since(t).as_secs_f64() * 1e6)
268            .unwrap_or(0.0)
269    })
270}
271
272thread_local! {
273    /// Microsegundos gastados entre cuadros en algo que NO es el repintado.
274    /// Lo suma [`cronometrar_otros`] y lo vacía [`tomar_otros`] al emitir la
275    /// muestra, así cada cuadro reporta lo suyo y no lo acumulado del proceso.
276    static OTROS: std::cell::Cell<f64> = const { std::cell::Cell::new(0.0) };
277}
278
279/// Cronómetro del tramo «otros»: se crea al entrar a un handler que corre entre
280/// cuadros y suma su duración al soltarse.
281///
282/// Es una guarda y no un `envolver(|| ...)` porque estos handlers toman `&mut
283/// self` y tienen varios `return` tempranos: con un closure habría que partir
284/// cada uno en dos funciones, y con dos marcas sueltas el primer `return` se
285/// llevaría la resta puesta. Apagado el perfilador no toca el reloj.
286pub(crate) struct CronometroOtros(Option<Instant>);
287
288impl CronometroOtros {
289    #[inline]
290    pub(crate) fn nuevo() -> Self {
291        Self(mark(enabled()))
292    }
293}
294
295impl Drop for CronometroOtros {
296    #[inline]
297    fn drop(&mut self) {
298        if let Some(t0) = self.0 {
299            let us = Instant::now().saturating_duration_since(t0).as_secs_f64() * 1e6;
300            OTROS.with(|c| c.set(c.get() + us));
301        }
302    }
303}
304
305/// Vacía el acumulador y devuelve lo que había.
306#[inline]
307pub(crate) fn tomar_otros(on: bool) -> f64 {
308    if !on {
309        return 0.0;
310    }
311    OTROS.with(|c| c.replace(0.0))
312}
313
314/// Sella el fin del cuadro. La llaman **los dos** caminos que presentan (el
315/// completo y el de retención); si sólo lo hiciera el completo, el hueco del
316/// cuadro siguiente a una retención se contaría dos veces.
317#[inline]
318pub(crate) fn sellar_fin(on: bool) {
319    if on {
320        FIN_PREVIO.with(|c| c.set(Some(Instant::now())));
321    }
322}
323
324/// `size_of::<View<Msg>>()` de la app que está corriendo, en bytes.
325///
326/// Va en el reporte como **huella del binario**. Una medición de bytes por
327/// frame no se puede leer sin él: el 2026-07-28 una tanda dio idéntica al byte
328/// después de bajar `View` de 2.472 B a 632, y el diagnóstico correcto no era
329/// "no ayudó" sino "este binario no lleva el cambio". Con la huella impresa, un
330/// `View 2472 B` en la salida delata al ejecutable viejo de un vistazo — el
331/// mismo papel que cumple `mount-reciclo` para distinguir "no ayudó" de "no
332/// corrió".
333/// Cuadros servidos por la **retención de cuadro entero** (el atajo de arriba
334/// de `handle_redraw`, que re-rasteriza la escena retenida y presenta sin pasar
335/// por mount/layout/paint).
336///
337/// Ese camino **no se muestrea** —no llega al `submit` de abajo—, y por eso
338/// hasta hoy era completamente invisible: no aparecía en ningún contador, así
339/// que un bug que lo disparara de más no se veía por ningún lado. Uno lo hizo,
340/// el 2026-08-10: la capa que saltea cuadros dejaba `has_gpu_paint` en `false`,
341/// la retención se creía habilitada y re-rasterizaba la escena limpiando a
342/// negro **sin volver a blittear el video** — cuadros negros intercalados en el
343/// reproductor. Contarlo es lo que hace que la próxima vez se vea.
344static RETENIDOS: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
345
346/// Cuadros que no se pintaron porque **la ventana estaba tapada** (`acquire`
347/// devolvió `Occluded`).
348///
349/// Ese camino es un `return` temprano arriba de `handle_redraw`: no llega a la
350/// muestra, así que hasta hoy no aparecía en ningún contador — el mismo agujero
351/// que en agosto hizo invisible el bug de los cuadros negros, y el motivo por el
352/// que existe [`RETENIDOS`].
353///
354/// Contarlo es lo que hace legible el tramo `ocio`: un hueco largo entre cuadros
355/// tiene dos lecturas que desde afuera se ven idénticas —el compositor dejó de
356/// pedir cuadros (bug) o la ventana no estaba a la vista (correcto, y nadie
357/// estaba mirando)— y sin este número no se pueden separar.
358static OCLUIDOS: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
359
360/// Suma un cuadro salteado por ventana tapada.
361pub(crate) fn contar_ocluido() {
362    OCLUIDOS.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
363}
364
365fn ocluidos() -> u64 {
366    OCLUIDOS.load(core::sync::atomic::Ordering::Relaxed)
367}
368
369/// Suma un cuadro servido por retención. Barato: un `fetch_add` relajado.
370pub(crate) fn contar_retenido() {
371    RETENIDOS.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
372}
373
374/// Cuántos van (acumulado). El reporte muestra el delta del lote.
375fn retenidos() -> u64 {
376    RETENIDOS.load(core::sync::atomic::Ordering::Relaxed)
377}
378
379static TAM_VIEW: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0);
380
381/// Registra la huella. La llama el redraw en cada frame (un store relajado).
382pub(crate) fn set_tam_view(n: usize) {
383    TAM_VIEW.store(n, core::sync::atomic::Ordering::Relaxed);
384}
385
386/// Censo de props raras del árbol (ver `llimphi_compositor::CensoRare`), tomado
387/// **una sola vez** por proceso.
388///
389/// Es la cifra que decide cuánto ahorra de verdad el reparto `ViewRare` en ESTA
390/// app: el que no lleva ninguna prop rara ahorra los ~1.840 B enteros; el que
391/// lleva alguna sólo mueve bytes al heap y suma una asignación. Se toma una vez
392/// porque el recorrido es O(nodos) y contaminaría la etapa en que corriera —
393/// la forma del árbol no cambia tanto como para necesitarlo por frame.
394static CENSO: (
395    core::sync::atomic::AtomicUsize,
396    core::sync::atomic::AtomicUsize,
397) = (
398    core::sync::atomic::AtomicUsize::new(0),
399    core::sync::atomic::AtomicUsize::new(0),
400);
401static CENSO_HECHO: core::sync::atomic::AtomicBool =
402    core::sync::atomic::AtomicBool::new(false);
403
404/// `true` la primera vez que se llama; `false` siempre después.
405pub(crate) fn censo_pendiente() -> bool {
406    !CENSO_HECHO.swap(true, core::sync::atomic::Ordering::Relaxed)
407}
408
409pub(crate) fn set_censo(nodos: usize, con_rare: usize) {
410    CENSO.0.store(nodos, core::sync::atomic::Ordering::Relaxed);
411    CENSO.1.store(con_rare, core::sync::atomic::Ordering::Relaxed);
412}
413
414/// Acumula una muestra y, cada `interval()` frames, imprime media + p95 por
415/// etapa a stderr. No-op si el perfilador está apagado.
416pub(crate) fn submit(s: FrameSample) {
417    let Some(n) = interval() else { return };
418    BUF.with(|b| {
419        let mut b = b.borrow_mut();
420        b.push(s);
421        if b.len() as u64 >= n {
422            report(&b);
423            b.clear();
424        }
425    });
426}
427
428fn report(samples: &[FrameSample]) {
429    let n = samples.len();
430    if n == 0 {
431        return;
432    }
433    let stat = |get: fn(&FrameSample) -> f64| -> (f64, f64) {
434        let mut v: Vec<f64> = samples.iter().map(get).collect();
435        let mean = v.iter().sum::<f64>() / n as f64;
436        v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
437        let idx = (((n as f64) * 0.95) as usize).min(n - 1);
438        (mean, v[idx])
439    };
440    let nodes = samples.iter().map(|s| s.n_nodes).max().unwrap_or(0);
441    let (t_m, t_p) = stat(|s| s.total_us);
442    let (v_m, v_p) = stat(|s| s.view_us);
443    let (mo_m, mo_p) = stat(|s| s.mount_us);
444    let (l_m, l_p) = stat(|s| s.layout_us);
445    let (p_m, p_p) = stat(|s| s.paint_us);
446    let (r_m, r_p) = stat(|s| s.raster_us);
447    let (g_m, g_p) = stat(|s| s.gpu_us);
448    let (pr_m, pr_p) = stat(|s| s.present_us);
449    let (aq_m, aq_p) = stat(|s| s.acquire_us);
450    let (en_m, en_p) = stat(|s| s.entre_us);
451    let (ot_m, _) = stat(|s| s.otros_us);
452    // El ocio de verdad: el hueco menos lo que el bucle pasó atendiendo otra
453    // cosa. Se deriva por resta para que las dos mitades SIEMPRE sumen `entre`
454    // — un tramo que no suma es un tramo que se puede ignorar sin notarlo.
455    let ocio_m = (en_m - ot_m).max(0.0);
456    // El reloj de pared del bucle: lo que el cuadro tardó de punta a punta.
457    // Se imprime junto con la cadencia que implica, porque «5 ms de total» y
458    // «18 fps» conviven sin contradicción sólo si se ve dónde está el resto.
459    let pared = en_m + aq_m + t_m;
460    let fps = if pared > 0.0 { 1e6 / pared } else { 0.0 };
461    // Delta de boundary-cache en la ventana del lote (acumulado último − primero).
462    let bh = samples.last().unwrap().boundary_hits - samples.first().unwrap().boundary_hits;
463    let bm = samples.last().unwrap().boundary_misses - samples.first().unwrap().boundary_misses;
464    let boundary = if bh + bm > 0 {
465        format!(" | boundary {bh} hits / {bm} miss")
466    } else {
467        String::new()
468    };
469    // Cuadros salteados enteros (nada cambió). En un reproductor con video de
470    // 24 fps a 60 Hz de vsync, lo esperable es ~3 de cada 5.
471    let salteados = samples.iter().filter(|s| s.frame_salteado).count();
472    // Los retenidos NO están en `samples` (ese camino no se muestrea), así que
473    // van por su propio contador, como delta desde el reporte anterior.
474    let retenidos_ahora = retenidos();
475    let retenidos_lote = {
476        static PREVIO: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
477        let previo = PREVIO.swap(retenidos_ahora, core::sync::atomic::Ordering::Relaxed);
478        retenidos_ahora.saturating_sub(previo)
479    };
480    let ocluidos_lote = {
481        static PREVIO: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
482        let ahora = ocluidos();
483        let previo = PREVIO.swap(ahora, core::sync::atomic::Ordering::Relaxed);
484        ahora.saturating_sub(previo)
485    };
486    let salto = if salteados > 0 || retenidos_lote > 0 || ocluidos_lote > 0 {
487        format!(
488            " | salteados {salteados}/{} retenidos {retenidos_lote} tapados {ocluidos_lote}",
489            samples.len()
490        )
491    } else {
492        String::new()
493    };
494    let reused = samples.iter().filter(|s| s.layout_reused).count();
495    let reciclados = samples.iter().filter(|s| s.mount_recycled).count();
496    let rec_layout = samples.iter().filter(|s| s.layout_recycled).count();
497    // Desglose del tramo `gpu`: sólo si la capa over estuvo activa en el lote.
498    let (op_m, _) = stat(|s| s.over_paint_us);
499    let (ot_m, _) = stat(|s| s.over_tex_us);
500    let (or_m, _) = stat(|s| s.over_raster_us);
501    let (sb_m, _) = stat(|s| s.submit_us);
502    let over = if op_m + ot_m + or_m + sb_m > 0.0 {
503        let cacheados = samples.iter().filter(|s| s.over_cache_hit).count();
504        format!(
505            " | over-paint {op_m:.0} over-tex {ot_m:.0} over-raster {or_m:.0} \
506             over-cache {cacheados}/{} submit {sb_m:.0}",
507            samples.len()
508        )
509    } else {
510        String::new()
511    };
512    // Asignaciones por frame. Sin contador instalado no inventamos ceros: `n/a`
513    // + cómo encenderlo. `resto` es el tramo posterior a paint —raster vello +
514    // pasadas GPU + present— y se deriva por resta, así ninguna asignación del
515    // frame queda sin etapa: el desglose SIEMPRE suma el total.
516    //
517    // ⚠️ Los contadores son del proceso entero, no del hilo. Lo que asigne otro
518    // hilo (el poll de wgpu, un worker de la app) mientras corre una etapa cae
519    // en la ventana de esa etapa. Para el trabajo del frame el sesgo es chico,
520    // pero un `resto` inflado puede ser el driver, no vello.
521    let alloc = if crate::alloc::instalado() {
522        let (a_m, a_p) = stat(|s| s.allocs as f64);
523        let kb = |get: fn(&FrameSample) -> f64| stat(get).0 / 1024.0;
524        let (av_m, am_m, al_m, ap_m) = (
525            stat(|s| s.allocs_view as f64).0,
526            stat(|s| s.allocs_mount as f64).0,
527            stat(|s| s.allocs_layout as f64).0,
528            stat(|s| s.allocs_paint as f64).0,
529        );
530        let b_m = kb(|s| s.alloc_bytes as f64);
531        let (bv, bm, bl, bp) = (
532            kb(|s| s.alloc_bytes_view as f64),
533            kb(|s| s.alloc_bytes_mount as f64),
534            kb(|s| s.alloc_bytes_layout as f64),
535            kb(|s| s.alloc_bytes_paint as f64),
536        );
537        let resto = (a_m - av_m - am_m - al_m - ap_m).max(0.0);
538        let resto_kb = (b_m - bv - bm - bl - bp).max(0.0);
539        format!(
540            " | alloc {a_m:.0}/{a_p:.0} ({b_m:.0} KB) [view {av_m:.0}·{bv:.0}K \
541             mount {am_m:.0}·{bm:.0}K layout {al_m:.0}·{bl:.0}K paint {ap_m:.0}·{bp:.0}K \
542             resto {resto:.0}·{resto_kb:.0}K]"
543        )
544    } else {
545        " | alloc n/a (--features llimphi-ui/perf-alloc)".to_string()
546    };
547    let tam_view = TAM_VIEW.load(core::sync::atomic::Ordering::Relaxed);
548    let censo_n = CENSO.0.load(core::sync::atomic::Ordering::Relaxed);
549    let censo_r = CENSO.1.load(core::sync::atomic::Ordering::Relaxed);
550    let rare = if censo_n > 0 {
551        let pct = 100.0 * censo_r as f64 / censo_n as f64;
552        format!(" rare={censo_r}/{censo_n}({pct:.0}%)")
553    } else {
554        String::new()
555    };
556    eprintln!(
557        "[llimphi-perf] n={n} nodes={nodes} View={tam_view}B{rare} | total {t_m:.0}/{t_p:.0} | \
558         view {v_m:.0}/{v_p:.0} mount {mo_m:.0}/{mo_p:.0} layout {l_m:.0}/{l_p:.0} \
559         paint {p_m:.0}/{p_p:.0} raster {r_m:.0}/{r_p:.0} gpu {g_m:.0}/{g_p:.0} \
560         present {pr_m:.0}/{pr_p:.0} µs (media/p95) | \
561         pared {pared:.0} = entre {en_m:.0}/{en_p:.0} (eventos {ot_m:.0} + ocio {ocio_m:.0}) + \
562         acquire {aq_m:.0}/{aq_p:.0} + \
563         total {t_m:.0} ⇒ {fps:.1} fps | layout-reuso {reused}/{n} mount-reciclo {reciclados}/{n} layout-reciclo {rec_layout}/{n}{salto}{boundary}{over}{alloc}"
564    );
565}