// ============================================================================= // medir_shaping :: el número que le faltaba a W4 (PLAN-LLIMPHI.md) // ----------------------------------------------------------------------------- // El plan midió el reparto de trabajo entre la app (layout + geometría) y el // kernel (aplanado + strips + raster), y con eso concluyó que el «IR de escena» // da 77 fps con los 69 widgets. Pero dejó un agujero declarado: // // «Lo NO medido, y es el riesgo que queda: el *shaping* de texto (parley) // corre del lado app y no se midió. Es más caro que taffy. Si domina, el // IR de escena se acerca a la columna jaula pura.» // // Esto lo mide. Y lo mide en DOS regímenes, porque medir uno solo daría una // respuesta falsa: // // FRÍO — caché vacía. Cada etiqueta se shapea de verdad. Es el primer // fotograma de una pantalla, o el momento en que cambia el idioma. // TIBIO — la caché de `Typesetter` ya tiene las etiquetas. En el hit no se // re-shapea: se arma la clave, se busca en el mapa y se CLONA el // `parley::Layout`. Es el régimen de TODOS los fotogramas que // siguen, y por lo tanto el que gobierna los fps sostenidos. // // Y un tercero, que es el que se parece a una app de verdad: // // MIXTO — la pantalla está tibia pero un puñado de etiquetas cambia cada // fotograma (un reloj, un contador, una barra de estado). // // Sobre los números se aplica la penalidad del intérprete YA MEDIDA por el // plan (30,3x: 3,29 µs nativo vs 99,64 µs bajo wasmi con la Config del // kernel), que es lo que costaría correr esto dentro de la jaula. // // Correr: cargo run -p llimphi-text --example medir_shaping --release // (en `--release`; en debug parley mide un orden de magnitud peor y el número // no significaría nada) // ============================================================================= use std::time::Instant; use llimphi_text::{Alignment, Typesetter}; /// Penalidad del intérprete wasmi medida en el spike de W4 (`scratchpad/penalidad`). const PENALIDAD_JAULA: f64 = 30.3; /// Cuentas de widgets que usa la tabla de W4, para poder comparar renglón a renglón. const CUENTAS: [usize; 3] = [50, 200, 500]; /// Costo del lado KERNEL (aplanado + strips + raster) a 480x400, escalar. /// Medido por el plan; se repite acá para que la comparación sea directa. fn ms_kernel(n: usize) -> f64 { match n { 50 => 0.539, 200 => 1.053, 500 => 1.462, _ => f64::NAN, } } /// Costo del lado APP sin texto (taffy + armar los BezPath), nativo. Del plan. fn ms_app_sin_texto(n: usize) -> f64 { match n { 50 => 0.043, 200 => 0.167, 500 => 0.380, _ => f64::NAN, } } /// Etiquetas como las de una UI real: cortas, variadas, en español. /// /// No es texto de relleno largo a propósito. Un párrafo de Lorem Ipsum mediría /// el shaping de un procesador de textos, no el de un chrome de escritorio —y /// lo que W4 decide es si el CHROME de wawa puede pintarse con Llimphi. fn etiquetas(n: usize) -> Vec { const BASE: [&str; 20] = [ "Archivo", "Editar", "Ver", "Ventana", "Ayuda", "Guardar como…", "Abrir reciente", "Cerrar pestaña", "Preferencias", "Salir", "Aceptar", "Cancelar", "Aplicar", "Descartar cambios", "Reintentar", "Sin conexión", "3 elementos seleccionados", "Listo", "Cargando…", "Buscar en el proyecto", ]; // Se sufija con el índice para que TODAS sean distintas: es el peor caso // honesto para la caché. Si se repitieran, el frío mediría de más los hits. (0..n) .map(|i| format!("{} {}", BASE[i % BASE.len()], i)) .collect() } /// Shapea todas las etiquetas una vez y devuelve el tiempo total. fn pasada(ts: &mut Typesetter, textos: &[String]) -> f64 { let t0 = Instant::now(); for texto in textos { let _ = ts.layout( texto, 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0, ); } t0.elapsed().as_secs_f64() * 1000.0 } fn main() { println!("============================================================="); println!(" W4.1 :: costo del shaping de texto (parley) del lado app"); println!("============================================================="); println!( "perfil: {}", if cfg!(debug_assertions) { "DEBUG ⚠️ los números NO sirven, correr con --release" } else { "release" } ); // El arranque del Typesetter (enumerar fuentes del sistema) se paga UNA vez // por proceso. No es costo de fotograma, pero conviene saber cuánto es. let t0 = Instant::now(); let mut ts = Typesetter::new(); let ms_arranque = t0.elapsed().as_secs_f64() * 1000.0; println!("arranque del Typesetter (una vez, no por frame): {ms_arranque:.2} ms\n"); for n in CUENTAS { let textos = etiquetas(n); // --- FRÍO: caché vacía, todo se shapea de verdad. --- let mut ts_frio = Typesetter::new(); let frio = pasada(&mut ts_frio, &textos); let st = ts_frio.cache_stats(); assert_eq!(st.hits, 0, "el frío no debería tener hits"); // --- TIBIO: la misma pasada, con la caché ya poblada. --- // Se corre varias veces y se toma la MEDIANA: una sola muestra a esta // escala la domina el ruido del planificador. let mut muestras: Vec = (0..9).map(|_| pasada(&mut ts, &textos)).collect(); // La primera de todas puebla la caché; se descarta. muestras.remove(0); muestras.sort_by(|a, b| a.partial_cmp(b).unwrap()); let tibio = muestras[muestras.len() / 2]; // --- MIXTO: 5% de las etiquetas cambia cada fotograma. --- let cambiantes = (n / 20).max(1); let mut ts_mix = Typesetter::new(); pasada(&mut ts_mix, &textos); // poblar let t0 = Instant::now(); for texto in textos.iter().take(n - cambiantes) { let _ = ts_mix.layout(texto, 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0); } for i in 0..cambiantes { // Texto nuevo cada vez: reloj, contador, barra de estado. let vivo = format!("actualizado {i} :: 12:34:56"); let _ = ts_mix.layout(&vivo, 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0); } let mixto = t0.elapsed().as_secs_f64() * 1000.0; // --- El veredicto --- let kernel = ms_kernel(n); let app_base = ms_app_sin_texto(n); println!("-------------------------------------------------------------"); println!(" {n} etiquetas ({cambiantes} cambian por frame en el caso mixto)"); println!("-------------------------------------------------------------"); println!(" shaping NATIVO"); println!(" frío (caché vacía) : {frio:8.3} ms [{} shapeadas]", st.misses); println!(" tibio (todo hit) : {tibio:8.3} ms"); println!(" mixto (5% cambia) : {mixto:8.3} ms"); println!(); println!(" proyección DENTRO DE LA JAULA (x{PENALIDAD_JAULA} medido)"); let jaula_frio = frio * PENALIDAD_JAULA; let jaula_tibio = tibio * PENALIDAD_JAULA; let jaula_mixto = mixto * PENALIDAD_JAULA; println!(" frío : {jaula_frio:8.3} ms"); println!(" tibio : {jaula_tibio:8.3} ms"); println!(" mixto : {jaula_mixto:8.3} ms"); println!(); println!(" FOTOGRAMA COMPLETO con IR de escena (app en jaula + kernel nativo)"); let app_jaula = app_base * PENALIDAD_JAULA; for (nombre, texto_jaula) in [ ("frío ", jaula_frio), ("tibio", jaula_tibio), ("mixto", jaula_mixto), ] { let total = app_jaula + texto_jaula + kernel; let fps = 1000.0 / total; let dominio = 100.0 * texto_jaula / total; println!( " {nombre}: app {app_jaula:6.2} + texto {texto_jaula:7.2} + kernel {kernel:5.2} \ = {total:7.2} ms → {fps:7.1} fps (texto = {dominio:.0}% del frame)" ); } println!(); } // ------------------------------------------------------------------------- // ¿De qué está hecho el camino TIBIO? // ------------------------------------------------------------------------- // Un hit de caché no es un lookup pelado. `layout_inner` arma un `ShapeKey` // y para eso aloca CUATRO `String` por llamada (`text`, `font_family`, // `features`, `variations`) antes de tocar el mapa, y en el hit CLONA el // `parley::Layout`. // // MEDIDO (2026-07-26): las alocaciones de la clave son el **4%**. La // hipótesis de que el churn de `String` dominaba era falsa. El 96% restante // es hashear la clave (leer todos los bytes del texto) y clonar el // `Layout` —o sea, copiar los vectores de glifos, runs y líneas—. // // Eso cambia dónde hay que optimizar si algún día hace falta: no en evitar // los `to_string`, sino en no devolver el layout POR VALOR (un `Rc` // cacheado ahorraría la copia entera). Es un cambio de API pública, no un // ajuste interno, y por eso se registra acá en vez de hacerse de apuro. { let textos = etiquetas(500); let t0 = Instant::now(); let mut sumidero = 0usize; for texto in &textos { // Exactamente las alocaciones que hace la construcción de la clave. let k_text = texto.to_string(); let k_family: Option = None; let k_features = String::new(); let k_variations = String::new(); sumidero += k_text.len() + k_features.len() + k_variations.len() + k_family.map_or(0, |f| f.len()); } let ms_clave = t0.elapsed().as_secs_f64() * 1000.0; let tibio_500: f64 = { let mut m: Vec = (0..5).map(|_| pasada(&mut ts, &etiquetas(500))).collect(); m.sort_by(|a, b| a.partial_cmp(b).unwrap()); m[m.len() / 2] }; println!("============================================================="); println!(" ¿De qué está hecho el camino tibio? (500 etiquetas)"); println!("============================================================="); println!(" tibio completo : {tibio_500:7.3} ms"); println!(" sólo armar las claves : {ms_clave:7.3} ms ({:.0}% del tibio)", 100.0 * ms_clave / tibio_500); println!(" resto (lookup + clone) : {:7.3} ms", tibio_500 - ms_clave); println!(" (sumidero anti-optimizador: {sumidero})"); println!(); } println!("============================================================="); println!(" Recordatorio de la tabla de W4 (con la que hay que comparar):"); println!(" IR de escena, SIN contar texto : 77 fps @500 · 164 @200 · 543 @50"); println!(" Jaula pura (rasterizar dentro): 18 fps @500 · 27 @200 · 57 @50"); println!(" Kernel puro (wire-view) : 685 fps @500, pero sólo ~15-18"); println!(" de los ~41 entries del MANUAL"); println!("============================================================="); }