tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0)
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 llimphi_hal::avisar!(
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}