// eventloop/perf.rs — Bloque 21 (PLAN-NUCLEO-RETENIDO.md): perfilador por frame // del camino COMPLETO de redraw (view → mount → layout → paint → raster → gpu → // present). Off por defecto. Se enciende con `LLIMPHI_PERF=1` (o `=N` para // reportar cada N frames; default 60). Cero costo apagado: una sola lectura de // env cacheada, y ni siquiera se toman timestamps. // // El camino `cache_hit` (retención de frame entero) NO se muestrea a propósito: // este contador mide sólo el trabajo que los Bloques 22–25 buscan reducir, así // que la regla del repo (§8: certificar con números, no screenshots) tiene una // base honesta de antes/después. use std::cell::RefCell; use std::sync::OnceLock; use crate::Instant; /// Intervalo de reporte en frames. `None` = perfilador apagado. Cacheado: una /// sola lectura de env por proceso. fn interval() -> Option { static CELL: OnceLock> = OnceLock::new(); *CELL.get_or_init(|| match std::env::var("LLIMPHI_PERF") { // El valor es el intervalo de reporte en frames. `1`/no-numérico ⇒ 60 // (default): así `LLIMPHI_PERF=1` significa "encendido, reporta cada 60" // y no floodea una línea por frame. `=120` = cada 120; `=0` = apagado. Ok(v) if !v.is_empty() && v != "0" => { Some(v.parse::().ok().filter(|&n| n >= 2).unwrap_or(60)) } _ => None, }) } /// `true` si el perfilador está activo. #[inline] pub(crate) fn enabled() -> bool { interval().is_some() } /// Cada cuántos cuadros reportar **dónde poner un `repaint_boundary`** /// (`LLIMPHI_BOUNDARY_HINT`). `None` = apagado, que es el default. /// /// Va aparte de `LLIMPHI_PERF` a propósito: el perfilador es barato y se deja /// puesto, éste hashea cada subárbol candidato por cuadro y se enciende un rato /// para averiguar algo. Mezclarlos habría hecho que `LLIMPHI_PERF=1` dejara de /// medir el frame y pasara a medirse a sí mismo. pub(crate) fn boundary_hint() -> Option { static CELL: OnceLock> = OnceLock::new(); *CELL.get_or_init(|| match std::env::var("LLIMPHI_BOUNDARY_HINT") { Ok(v) if !v.is_empty() && v != "0" => { Some(v.parse::().ok().filter(|&n| n >= 2).unwrap_or(120)) } _ => None, }) } thread_local! { /// Cuadros vistos por el detector desde el último reporte. static CUADROS_HINT: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// Imprime las sugerencias cada `boundary_hint()` cuadros. Silencio absoluto si /// no hay ninguna: un panel que sí cambia no tiene por qué generar ruido. pub(crate) fn reportar_boundaries(sugerencias: &[llimphi_compositor::SugerenciaBoundary]) { let Some(cada) = boundary_hint() else { return }; let n = CUADROS_HINT.with(|c| { let v = c.get() + 1; c.set(if v >= cada { 0 } else { v }); v }); if n < cada || sugerencias.is_empty() { return; } let nodos: usize = sugerencias.iter().map(|s| s.nodos).sum(); println!( "[llimphi] {} subárbol(es) quieto(s), {nodos} nodos que se repintan de gusto \ — poneles .repaint_boundary(clave):", sugerencias.len() ); for s in sugerencias { println!("{}", llimphi_compositor::describir(s)); } } /// Toma un timestamp sólo si el perfilador está activo (`on`). Cuando está /// apagado devuelve `None` y no toca el reloj. #[inline] pub(crate) fn mark(on: bool) -> Option { if on { Some(Instant::now()) } else { None } } /// Microsegundos entre dos marcas. `0.0` si alguna es `None` (perfilador off). #[inline] pub(crate) fn us(a: Option, b: Option) -> f64 { match (a, b) { (Some(a), Some(b)) => b.saturating_duration_since(a).as_secs_f64() * 1e6, _ => 0.0, } } /// Lectura del contador de asignaciones (`crate::alloc`) en un punto del frame. #[derive(Clone, Copy, Default)] pub(crate) struct AllocMark { asignaciones: u64, bytes: u64, } /// Toma una lectura del contador de heap sólo si el perfilador está activo. /// Cuando está apagado no toca ni los atómicos. #[inline] pub(crate) fn alloc_mark(on: bool) -> Option { if on { let (asignaciones, bytes, _libres) = crate::alloc::snapshot(); Some(AllocMark { asignaciones, bytes, }) } else { None } } /// Asignaciones entre dos lecturas. `0` si alguna es `None` (perfilador off) o /// si nadie instaló el contador (entonces ambas leen el mismo cero). #[inline] pub(crate) fn allocs(a: Option, b: Option) -> u64 { match (a, b) { (Some(a), Some(b)) => b.asignaciones.saturating_sub(a.asignaciones), _ => 0, } } /// Bytes pedidos al heap entre dos lecturas (crecimiento neto en los realloc). #[inline] pub(crate) fn alloc_bytes(a: Option, b: Option) -> u64 { match (a, b) { (Some(a), Some(b)) => b.bytes.saturating_sub(a.bytes), _ => 0, } } /// Una muestra de frame: microsegundos por etapa + conteo de nodos. #[derive(Clone, Copy, Default)] pub(crate) struct FrameSample { pub view_us: f64, pub mount_us: f64, pub layout_us: f64, /// Incluye reconciles (anim/hero/size), mount+layout del overlay, hit-tests /// y el paint de la escena principal (fills/text/ghosts/ripple/overlay). pub paint_us: f64, /// Rasterización vello de la escena principal (`Renderer::render`). pub raster_us: f64, /// Pasadas GPU directas + backdrop/filter + composites + submit. pub gpu_us: f64, /// Desglose del tramo `gpu` para la capa `over` (subárboles `View::over`), /// que es el camino caro cuando una app tiene fondo/OSC encima del canvas: /// crear la scratch del tamaño del frame · rasterizarla con vello · el /// `submit` final. Se reportan sólo si alguno es no-cero. /// Construir la escena vello de la capa over: corre los `paint_with` de /// la app (p. ej. el fondo animado de media). Puro CPU. pub over_paint_us: f64, pub over_tex_us: f64, pub over_raster_us: f64, /// `true` si el cuadro **reusó** la capa over ya dibujada (misma huella de /// escena) en vez de rasterizarla. En régimen —video corriendo, barra de /// controles quieta— tiene que ser la abrumadora mayoría; si el reporte /// muestra pocos aciertos con la UI quieta, algo está latiendo de más. pub over_cache_hit: bool, /// `true` si el cuadro se salteó entero (nada cambió: se presentó la /// intermedia tal como quedó). Ver `App::gpu_content_changed`. pub frame_salteado: bool, pub submit_us: f64, pub present_us: f64, /// Lo que tardó `Surface::acquire()` — o sea **la espera del vsync**. En /// `Fifo`/`AutoVsync` esa llamada BLOQUEA hasta que el compositor libera un /// buffer, así que acá es donde se ve la contrapresión de mirada. /// /// Existe porque hasta el 2026-08-25 el `acquire` caía **fuera** del /// perfilador (se hace antes de `m_start`), y ése era el agujero exacto por /// el que se colaba el diagnóstico de media: `total` daba 5 ms mientras el /// reproductor sostenía 10–18 cuadros por segundo, y el tiempo que faltaba /// no aparecía en ninguna etapa. «El 96 % es espera de vsync» era una /// conjetura sin contador; ahora es una columna. pub acquire_us: f64, /// Desde que terminó el cuadro anterior hasta que empezó éste: el rato en /// que el bucle **no estaba corriendo**. Con `acquire_us` cierra el reloj de /// pared — `entre + acquire + total` es el período real del bucle, y ahí se /// lee si la app trabaja de más, si la frena el compositor, o si /// directamente nadie le pidió que repintara. pub entre_us: f64, /// La parte de [`Self::entre_us`] que el bucle pasó **trabajando en otra /// cosa**: eventos de ventana que no son el repintado (puntero, teclas, /// resize), `Msg` del canal (`Handle::dispatch`, los ticks periódicos) y el /// `about_to_wait`. El resto de `entre` es ocio real — dormido esperando /// que el compositor pida el cuadro siguiente. /// /// Existe porque `entre` solo no alcanza para decidir nada: 40 ms de hueco /// se leen igual si la app estuvo quieta esperando a mirada que si estuvo /// moliendo hit-tests por cada movimiento del puntero, y el arreglo de cada /// caso está en un crate distinto. Medido el 26-ago en media: `entre` era el /// 80 % del período y nadie sabía de cuál de los dos se trataba. pub otros_us: f64, pub total_us: f64, pub n_nodes: usize, /// Aciertos/fallos ACUMULADOS del caché de RepaintBoundary (Bloque 23) al /// cierre del frame. El reporte muestra el delta del lote (hits/misses en /// esta ventana) — sólo si hubo actividad de boundary. pub boundary_hits: u64, pub boundary_misses: u64, /// `true` si este frame reusó el layout retenido (Bloque 24) en vez de /// hacer `clear`+rebuild. El reporte muestra cuántos frames del lote lo /// hicieron. pub layout_reused: bool, /// `true` si el mount reusó los buffers del frame anterior (Bloque 21, /// palanca 1). Se reporta aparte de los bytes porque "la optimización no /// ayudó" y "la optimización no corrió" se ven igual mirando sólo los bytes /// — y la primera versión de esto no corría nunca. pub mount_recycled: bool, /// `true` si el layout escribió encima del `ComputedLayout` del frame /// anterior en vez de construir uno nuevo. Se reporta por el mismo motivo /// que `mount_recycled`, y la lección se pagó una vez: sin este campo, un /// −0 % en los bytes no se distingue de que el código no corrió. pub layout_recycled: bool, /// Asignaciones de heap del frame completo y desglose por las mismas etapas /// que los µs (`view` incluye el `view()` de la app + los builders diferidos /// + el size-anim; `paint` incluye reconciles, overlay, hit-tests y la /// construcción de la `Scene`). Todo cero si nadie instaló /// [`crate::alloc::ContadorAlloc`] — el reporte lo distingue con `n/a`. pub allocs: u64, pub allocs_view: u64, pub allocs_mount: u64, pub allocs_layout: u64, pub allocs_paint: u64, /// Bytes pedidos al heap en el frame (crecimiento neto en los `realloc`) y /// el mismo desglose por etapa. Sin bytes por etapa no se puede saber si las /// pocas asignaciones de una etapa son grandes o chicas — y esa es /// justamente la pregunta que decide qué se ataca primero. pub alloc_bytes: u64, pub alloc_bytes_view: u64, pub alloc_bytes_mount: u64, pub alloc_bytes_layout: u64, pub alloc_bytes_paint: u64, } thread_local! { static BUF: RefCell> = const { RefCell::new(Vec::new()) }; } thread_local! { /// Cuándo terminó el último cuadro (después del `present`). De acá sale /// [`hueco`]. `None` = todavía no hubo ninguno, y el primer cuadro no /// inventa un hueco. static FIN_PREVIO: std::cell::Cell> = const { std::cell::Cell::new(None) }; } /// Microsegundos desde el fin del cuadro anterior. `0.0` con el perfilador /// apagado o en el primer cuadro. #[inline] pub(crate) fn hueco(on: bool) -> f64 { if !on { return 0.0; } FIN_PREVIO.with(|c| { c.get() .map(|t| Instant::now().saturating_duration_since(t).as_secs_f64() * 1e6) .unwrap_or(0.0) }) } thread_local! { /// Microsegundos gastados entre cuadros en algo que NO es el repintado. /// Lo suma [`cronometrar_otros`] y lo vacía [`tomar_otros`] al emitir la /// muestra, así cada cuadro reporta lo suyo y no lo acumulado del proceso. static OTROS: std::cell::Cell = const { std::cell::Cell::new(0.0) }; } /// Cronómetro del tramo «otros»: se crea al entrar a un handler que corre entre /// cuadros y suma su duración al soltarse. /// /// Es una guarda y no un `envolver(|| ...)` porque estos handlers toman `&mut /// self` y tienen varios `return` tempranos: con un closure habría que partir /// cada uno en dos funciones, y con dos marcas sueltas el primer `return` se /// llevaría la resta puesta. Apagado el perfilador no toca el reloj. pub(crate) struct CronometroOtros(Option); impl CronometroOtros { #[inline] pub(crate) fn nuevo() -> Self { Self(mark(enabled())) } } impl Drop for CronometroOtros { #[inline] fn drop(&mut self) { if let Some(t0) = self.0 { let us = Instant::now().saturating_duration_since(t0).as_secs_f64() * 1e6; OTROS.with(|c| c.set(c.get() + us)); } } } /// Vacía el acumulador y devuelve lo que había. #[inline] pub(crate) fn tomar_otros(on: bool) -> f64 { if !on { return 0.0; } OTROS.with(|c| c.replace(0.0)) } /// Sella el fin del cuadro. La llaman **los dos** caminos que presentan (el /// completo y el de retención); si sólo lo hiciera el completo, el hueco del /// cuadro siguiente a una retención se contaría dos veces. #[inline] pub(crate) fn sellar_fin(on: bool) { if on { FIN_PREVIO.with(|c| c.set(Some(Instant::now()))); } } /// `size_of::>()` de la app que está corriendo, en bytes. /// /// Va en el reporte como **huella del binario**. Una medición de bytes por /// frame no se puede leer sin él: el 2026-07-28 una tanda dio idéntica al byte /// después de bajar `View` de 2.472 B a 632, y el diagnóstico correcto no era /// "no ayudó" sino "este binario no lleva el cambio". Con la huella impresa, un /// `View 2472 B` en la salida delata al ejecutable viejo de un vistazo — el /// mismo papel que cumple `mount-reciclo` para distinguir "no ayudó" de "no /// corrió". /// Cuadros servidos por la **retención de cuadro entero** (el atajo de arriba /// de `handle_redraw`, que re-rasteriza la escena retenida y presenta sin pasar /// por mount/layout/paint). /// /// Ese camino **no se muestrea** —no llega al `submit` de abajo—, y por eso /// hasta hoy era completamente invisible: no aparecía en ningún contador, así /// que un bug que lo disparara de más no se veía por ningún lado. Uno lo hizo, /// el 2026-08-10: la capa que saltea cuadros dejaba `has_gpu_paint` en `false`, /// la retención se creía habilitada y re-rasterizaba la escena limpiando a /// negro **sin volver a blittear el video** — cuadros negros intercalados en el /// reproductor. Contarlo es lo que hace que la próxima vez se vea. static RETENIDOS: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0); /// Cuadros que no se pintaron porque **la ventana estaba tapada** (`acquire` /// devolvió `Occluded`). /// /// Ese camino es un `return` temprano arriba de `handle_redraw`: no llega a la /// muestra, así que hasta hoy no aparecía en ningún contador — el mismo agujero /// que en agosto hizo invisible el bug de los cuadros negros, y el motivo por el /// que existe [`RETENIDOS`]. /// /// Contarlo es lo que hace legible el tramo `ocio`: un hueco largo entre cuadros /// tiene dos lecturas que desde afuera se ven idénticas —el compositor dejó de /// pedir cuadros (bug) o la ventana no estaba a la vista (correcto, y nadie /// estaba mirando)— y sin este número no se pueden separar. static OCLUIDOS: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0); /// Suma un cuadro salteado por ventana tapada. pub(crate) fn contar_ocluido() { OCLUIDOS.fetch_add(1, core::sync::atomic::Ordering::Relaxed); } fn ocluidos() -> u64 { OCLUIDOS.load(core::sync::atomic::Ordering::Relaxed) } /// Suma un cuadro servido por retención. Barato: un `fetch_add` relajado. pub(crate) fn contar_retenido() { RETENIDOS.fetch_add(1, core::sync::atomic::Ordering::Relaxed); } /// Cuántos van (acumulado). El reporte muestra el delta del lote. fn retenidos() -> u64 { RETENIDOS.load(core::sync::atomic::Ordering::Relaxed) } static TAM_VIEW: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0); /// Registra la huella. La llama el redraw en cada frame (un store relajado). pub(crate) fn set_tam_view(n: usize) { TAM_VIEW.store(n, core::sync::atomic::Ordering::Relaxed); } /// Censo de props raras del árbol (ver `llimphi_compositor::CensoRare`), tomado /// **una sola vez** por proceso. /// /// Es la cifra que decide cuánto ahorra de verdad el reparto `ViewRare` en ESTA /// app: el que no lleva ninguna prop rara ahorra los ~1.840 B enteros; el que /// lleva alguna sólo mueve bytes al heap y suma una asignación. Se toma una vez /// porque el recorrido es O(nodos) y contaminaría la etapa en que corriera — /// la forma del árbol no cambia tanto como para necesitarlo por frame. static CENSO: ( core::sync::atomic::AtomicUsize, core::sync::atomic::AtomicUsize, ) = ( core::sync::atomic::AtomicUsize::new(0), core::sync::atomic::AtomicUsize::new(0), ); static CENSO_HECHO: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); /// `true` la primera vez que se llama; `false` siempre después. pub(crate) fn censo_pendiente() -> bool { !CENSO_HECHO.swap(true, core::sync::atomic::Ordering::Relaxed) } pub(crate) fn set_censo(nodos: usize, con_rare: usize) { CENSO.0.store(nodos, core::sync::atomic::Ordering::Relaxed); CENSO.1.store(con_rare, core::sync::atomic::Ordering::Relaxed); } /// Acumula una muestra y, cada `interval()` frames, imprime media + p95 por /// etapa a stderr. No-op si el perfilador está apagado. pub(crate) fn submit(s: FrameSample) { let Some(n) = interval() else { return }; BUF.with(|b| { let mut b = b.borrow_mut(); b.push(s); if b.len() as u64 >= n { report(&b); b.clear(); } }); } fn report(samples: &[FrameSample]) { let n = samples.len(); if n == 0 { return; } let stat = |get: fn(&FrameSample) -> f64| -> (f64, f64) { let mut v: Vec = samples.iter().map(get).collect(); let mean = v.iter().sum::() / n as f64; v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); let idx = (((n as f64) * 0.95) as usize).min(n - 1); (mean, v[idx]) }; let nodes = samples.iter().map(|s| s.n_nodes).max().unwrap_or(0); let (t_m, t_p) = stat(|s| s.total_us); let (v_m, v_p) = stat(|s| s.view_us); let (mo_m, mo_p) = stat(|s| s.mount_us); let (l_m, l_p) = stat(|s| s.layout_us); let (p_m, p_p) = stat(|s| s.paint_us); let (r_m, r_p) = stat(|s| s.raster_us); let (g_m, g_p) = stat(|s| s.gpu_us); let (pr_m, pr_p) = stat(|s| s.present_us); let (aq_m, aq_p) = stat(|s| s.acquire_us); let (en_m, en_p) = stat(|s| s.entre_us); let (ot_m, _) = stat(|s| s.otros_us); // El ocio de verdad: el hueco menos lo que el bucle pasó atendiendo otra // cosa. Se deriva por resta para que las dos mitades SIEMPRE sumen `entre` // — un tramo que no suma es un tramo que se puede ignorar sin notarlo. let ocio_m = (en_m - ot_m).max(0.0); // El reloj de pared del bucle: lo que el cuadro tardó de punta a punta. // Se imprime junto con la cadencia que implica, porque «5 ms de total» y // «18 fps» conviven sin contradicción sólo si se ve dónde está el resto. let pared = en_m + aq_m + t_m; let fps = if pared > 0.0 { 1e6 / pared } else { 0.0 }; // Delta de boundary-cache en la ventana del lote (acumulado último − primero). let bh = samples.last().unwrap().boundary_hits - samples.first().unwrap().boundary_hits; let bm = samples.last().unwrap().boundary_misses - samples.first().unwrap().boundary_misses; let boundary = if bh + bm > 0 { format!(" | boundary {bh} hits / {bm} miss") } else { String::new() }; // Cuadros salteados enteros (nada cambió). En un reproductor con video de // 24 fps a 60 Hz de vsync, lo esperable es ~3 de cada 5. let salteados = samples.iter().filter(|s| s.frame_salteado).count(); // Los retenidos NO están en `samples` (ese camino no se muestrea), así que // van por su propio contador, como delta desde el reporte anterior. let retenidos_ahora = retenidos(); let retenidos_lote = { static PREVIO: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0); let previo = PREVIO.swap(retenidos_ahora, core::sync::atomic::Ordering::Relaxed); retenidos_ahora.saturating_sub(previo) }; let ocluidos_lote = { static PREVIO: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0); let ahora = ocluidos(); let previo = PREVIO.swap(ahora, core::sync::atomic::Ordering::Relaxed); ahora.saturating_sub(previo) }; let salto = if salteados > 0 || retenidos_lote > 0 || ocluidos_lote > 0 { format!( " | salteados {salteados}/{} retenidos {retenidos_lote} tapados {ocluidos_lote}", samples.len() ) } else { String::new() }; let reused = samples.iter().filter(|s| s.layout_reused).count(); let reciclados = samples.iter().filter(|s| s.mount_recycled).count(); let rec_layout = samples.iter().filter(|s| s.layout_recycled).count(); // Desglose del tramo `gpu`: sólo si la capa over estuvo activa en el lote. let (op_m, _) = stat(|s| s.over_paint_us); let (ot_m, _) = stat(|s| s.over_tex_us); let (or_m, _) = stat(|s| s.over_raster_us); let (sb_m, _) = stat(|s| s.submit_us); let over = if op_m + ot_m + or_m + sb_m > 0.0 { let cacheados = samples.iter().filter(|s| s.over_cache_hit).count(); format!( " | over-paint {op_m:.0} over-tex {ot_m:.0} over-raster {or_m:.0} \ over-cache {cacheados}/{} submit {sb_m:.0}", samples.len() ) } else { String::new() }; // Asignaciones por frame. Sin contador instalado no inventamos ceros: `n/a` // + cómo encenderlo. `resto` es el tramo posterior a paint —raster vello + // pasadas GPU + present— y se deriva por resta, así ninguna asignación del // frame queda sin etapa: el desglose SIEMPRE suma el total. // // ⚠️ Los contadores son del proceso entero, no del hilo. Lo que asigne otro // hilo (el poll de wgpu, un worker de la app) mientras corre una etapa cae // en la ventana de esa etapa. Para el trabajo del frame el sesgo es chico, // pero un `resto` inflado puede ser el driver, no vello. let alloc = if crate::alloc::instalado() { let (a_m, a_p) = stat(|s| s.allocs as f64); let kb = |get: fn(&FrameSample) -> f64| stat(get).0 / 1024.0; let (av_m, am_m, al_m, ap_m) = ( stat(|s| s.allocs_view as f64).0, stat(|s| s.allocs_mount as f64).0, stat(|s| s.allocs_layout as f64).0, stat(|s| s.allocs_paint as f64).0, ); let b_m = kb(|s| s.alloc_bytes as f64); let (bv, bm, bl, bp) = ( kb(|s| s.alloc_bytes_view as f64), kb(|s| s.alloc_bytes_mount as f64), kb(|s| s.alloc_bytes_layout as f64), kb(|s| s.alloc_bytes_paint as f64), ); let resto = (a_m - av_m - am_m - al_m - ap_m).max(0.0); let resto_kb = (b_m - bv - bm - bl - bp).max(0.0); format!( " | alloc {a_m:.0}/{a_p:.0} ({b_m:.0} KB) [view {av_m:.0}·{bv:.0}K \ mount {am_m:.0}·{bm:.0}K layout {al_m:.0}·{bl:.0}K paint {ap_m:.0}·{bp:.0}K \ resto {resto:.0}·{resto_kb:.0}K]" ) } else { " | alloc n/a (--features llimphi-ui/perf-alloc)".to_string() }; let tam_view = TAM_VIEW.load(core::sync::atomic::Ordering::Relaxed); let censo_n = CENSO.0.load(core::sync::atomic::Ordering::Relaxed); let censo_r = CENSO.1.load(core::sync::atomic::Ordering::Relaxed); let rare = if censo_n > 0 { let pct = 100.0 * censo_r as f64 / censo_n as f64; format!(" rare={censo_r}/{censo_n}({pct:.0}%)") } else { String::new() }; eprintln!( "[llimphi-perf] n={n} nodes={nodes} View={tam_view}B{rare} | total {t_m:.0}/{t_p:.0} | \ view {v_m:.0}/{v_p:.0} mount {mo_m:.0}/{mo_p:.0} layout {l_m:.0}/{l_p:.0} \ paint {p_m:.0}/{p_p:.0} raster {r_m:.0}/{r_p:.0} gpu {g_m:.0}/{g_p:.0} \ present {pr_m:.0}/{pr_p:.0} µs (media/p95) | \ pared {pared:.0} = entre {en_m:.0}/{en_p:.0} (eventos {ot_m:.0} + ocio {ocio_m:.0}) + \ acquire {aq_m:.0}/{aq_p:.0} + \ total {t_m:.0} ⇒ {fps:.1} fps | layout-reuso {reused}/{n} mount-reciclo {reciclados}/{n} layout-reciclo {rec_layout}/{n}{salto}{boundary}{over}{alloc}" ); }