tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1// =============================================================================
2// medir_shaping :: el número que le faltaba a W4 (PLAN-LLIMPHI.md)
3// -----------------------------------------------------------------------------
4// El plan midió el reparto de trabajo entre la app (layout + geometría) y el
5// kernel (aplanado + strips + raster), y con eso concluyó que el «IR de escena»
6// da 77 fps con los 69 widgets. Pero dejó un agujero declarado:
7//
8// «Lo NO medido, y es el riesgo que queda: el *shaping* de texto (parley)
9// corre del lado app y no se midió. Es más caro que taffy. Si domina, el
10// IR de escena se acerca a la columna jaula pura.»
11//
12// Esto lo mide. Y lo mide en DOS regímenes, porque medir uno solo daría una
13// respuesta falsa:
14//
15// FRÍO — caché vacía. Cada etiqueta se shapea de verdad. Es el primer
16// fotograma de una pantalla, o el momento en que cambia el idioma.
17// TIBIO — la caché de `Typesetter` ya tiene las etiquetas. En el hit no se
18// re-shapea: se arma la clave, se busca en el mapa y se CLONA el
19// `parley::Layout`. Es el régimen de TODOS los fotogramas que
20// siguen, y por lo tanto el que gobierna los fps sostenidos.
21//
22// Y un tercero, que es el que se parece a una app de verdad:
23//
24// MIXTO — la pantalla está tibia pero un puñado de etiquetas cambia cada
25// fotograma (un reloj, un contador, una barra de estado).
26//
27// Sobre los números se aplica la penalidad del intérprete YA MEDIDA por el
28// plan (30,3x: 3,29 µs nativo vs 99,64 µs bajo wasmi con la Config del
29// kernel), que es lo que costaría correr esto dentro de la jaula.
30//
31// Correr: cargo run -p llimphi-text --example medir_shaping --release
32// (en `--release`; en debug parley mide un orden de magnitud peor y el número
33// no significaría nada)
34// =============================================================================
35
36use std::time::Instant;
37
38use llimphi_text::{Alignment, Typesetter};
39
40/// Penalidad del intérprete wasmi medida en el spike de W4 (`scratchpad/penalidad`).
41const PENALIDAD_JAULA: f64 = 30.3;
42
43/// Cuentas de widgets que usa la tabla de W4, para poder comparar renglón a renglón.
44const CUENTAS: [usize; 3] = [50, 200, 500];
45
46/// Costo del lado KERNEL (aplanado + strips + raster) a 480x400, escalar.
47/// Medido por el plan; se repite acá para que la comparación sea directa.
48fn ms_kernel(n: usize) -> f64 {
49 match n {
50 50 => 0.539,
51 200 => 1.053,
52 500 => 1.462,
53 _ => f64::NAN,
54 }
55}
56
57/// Costo del lado APP sin texto (taffy + armar los BezPath), nativo. Del plan.
58fn ms_app_sin_texto(n: usize) -> f64 {
59 match n {
60 50 => 0.043,
61 200 => 0.167,
62 500 => 0.380,
63 _ => f64::NAN,
64 }
65}
66
67/// Etiquetas como las de una UI real: cortas, variadas, en español.
68///
69/// No es texto de relleno largo a propósito. Un párrafo de Lorem Ipsum mediría
70/// el shaping de un procesador de textos, no el de un chrome de escritorio —y
71/// lo que W4 decide es si el CHROME de wawa puede pintarse con Llimphi.
72fn etiquetas(n: usize) -> Vec<String> {
73 const BASE: [&str; 20] = [
74 "Archivo", "Editar", "Ver", "Ventana", "Ayuda",
75 "Guardar como…", "Abrir reciente", "Cerrar pestaña", "Preferencias", "Salir",
76 "Aceptar", "Cancelar", "Aplicar", "Descartar cambios", "Reintentar",
77 "Sin conexión", "3 elementos seleccionados", "Listo", "Cargando…", "Buscar en el proyecto",
78 ];
79 // Se sufija con el índice para que TODAS sean distintas: es el peor caso
80 // honesto para la caché. Si se repitieran, el frío mediría de más los hits.
81 (0..n)
82 .map(|i| format!("{} {}", BASE[i % BASE.len()], i))
83 .collect()
84}
85
86/// Shapea todas las etiquetas una vez y devuelve el tiempo total.
87fn pasada(ts: &mut Typesetter, textos: &[String]) -> f64 {
88 let t0 = Instant::now();
89 for texto in textos {
90 let _ = ts.layout(
91 texto,
92 14.0,
93 None,
94 Alignment::Start,
95 1.2,
96 false,
97 None,
98 400.0,
99 false,
100 false,
101 0.0,
102 0.0,
103 );
104 }
105 t0.elapsed().as_secs_f64() * 1000.0
106}
107
108fn main() {
109 println!("=============================================================");
110 println!(" W4.1 :: costo del shaping de texto (parley) del lado app");
111 println!("=============================================================");
112 println!(
113 "perfil: {}",
114 if cfg!(debug_assertions) {
115 "DEBUG ⚠️ los números NO sirven, correr con --release"
116 } else {
117 "release"
118 }
119 );
120
121 // El arranque del Typesetter (enumerar fuentes del sistema) se paga UNA vez
122 // por proceso. No es costo de fotograma, pero conviene saber cuánto es.
123 let t0 = Instant::now();
124 let mut ts = Typesetter::new();
125 let ms_arranque = t0.elapsed().as_secs_f64() * 1000.0;
126 println!("arranque del Typesetter (una vez, no por frame): {ms_arranque:.2} ms\n");
127
128 for n in CUENTAS {
129 let textos = etiquetas(n);
130
131 // --- FRÍO: caché vacía, todo se shapea de verdad. ---
132 let mut ts_frio = Typesetter::new();
133 let frio = pasada(&mut ts_frio, &textos);
134 let st = ts_frio.cache_stats();
135 assert_eq!(st.hits, 0, "el frío no debería tener hits");
136
137 // --- TIBIO: la misma pasada, con la caché ya poblada. ---
138 // Se corre varias veces y se toma la MEDIANA: una sola muestra a esta
139 // escala la domina el ruido del planificador.
140 let mut muestras: Vec<f64> = (0..9).map(|_| pasada(&mut ts, &textos)).collect();
141 // La primera de todas puebla la caché; se descarta.
142 muestras.remove(0);
143 muestras.sort_by(|a, b| a.partial_cmp(b).unwrap());
144 let tibio = muestras[muestras.len() / 2];
145
146 // --- MIXTO: 5% de las etiquetas cambia cada fotograma. ---
147 let cambiantes = (n / 20).max(1);
148 let mut ts_mix = Typesetter::new();
149 pasada(&mut ts_mix, &textos); // poblar
150 let t0 = Instant::now();
151 for texto in textos.iter().take(n - cambiantes) {
152 let _ = ts_mix.layout(texto, 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
153 }
154 for i in 0..cambiantes {
155 // Texto nuevo cada vez: reloj, contador, barra de estado.
156 let vivo = format!("actualizado {i} :: 12:34:56");
157 let _ = ts_mix.layout(&vivo, 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
158 }
159 let mixto = t0.elapsed().as_secs_f64() * 1000.0;
160
161 // --- El veredicto ---
162 let kernel = ms_kernel(n);
163 let app_base = ms_app_sin_texto(n);
164
165 println!("-------------------------------------------------------------");
166 println!(" {n} etiquetas ({cambiantes} cambian por frame en el caso mixto)");
167 println!("-------------------------------------------------------------");
168 println!(" shaping NATIVO");
169 println!(" frío (caché vacía) : {frio:8.3} ms [{} shapeadas]", st.misses);
170 println!(" tibio (todo hit) : {tibio:8.3} ms");
171 println!(" mixto (5% cambia) : {mixto:8.3} ms");
172 println!();
173 println!(" proyección DENTRO DE LA JAULA (x{PENALIDAD_JAULA} medido)");
174 let jaula_frio = frio * PENALIDAD_JAULA;
175 let jaula_tibio = tibio * PENALIDAD_JAULA;
176 let jaula_mixto = mixto * PENALIDAD_JAULA;
177 println!(" frío : {jaula_frio:8.3} ms");
178 println!(" tibio : {jaula_tibio:8.3} ms");
179 println!(" mixto : {jaula_mixto:8.3} ms");
180 println!();
181 println!(" FOTOGRAMA COMPLETO con IR de escena (app en jaula + kernel nativo)");
182 let app_jaula = app_base * PENALIDAD_JAULA;
183 for (nombre, texto_jaula) in [
184 ("frío ", jaula_frio),
185 ("tibio", jaula_tibio),
186 ("mixto", jaula_mixto),
187 ] {
188 let total = app_jaula + texto_jaula + kernel;
189 let fps = 1000.0 / total;
190 let dominio = 100.0 * texto_jaula / total;
191 println!(
192 " {nombre}: app {app_jaula:6.2} + texto {texto_jaula:7.2} + kernel {kernel:5.2} \
193 = {total:7.2} ms → {fps:7.1} fps (texto = {dominio:.0}% del frame)"
194 );
195 }
196 println!();
197 }
198
199 // -------------------------------------------------------------------------
200 // ¿De qué está hecho el camino TIBIO?
201 // -------------------------------------------------------------------------
202 // Un hit de caché no es un lookup pelado. `layout_inner` arma un `ShapeKey`
203 // y para eso aloca CUATRO `String` por llamada (`text`, `font_family`,
204 // `features`, `variations`) antes de tocar el mapa, y en el hit CLONA el
205 // `parley::Layout`.
206 //
207 // MEDIDO (2026-07-26): las alocaciones de la clave son el **4%**. La
208 // hipótesis de que el churn de `String` dominaba era falsa. El 96% restante
209 // es hashear la clave (leer todos los bytes del texto) y clonar el
210 // `Layout` —o sea, copiar los vectores de glifos, runs y líneas—.
211 //
212 // Eso cambia dónde hay que optimizar si algún día hace falta: no en evitar
213 // los `to_string`, sino en no devolver el layout POR VALOR (un `Rc<Layout>`
214 // cacheado ahorraría la copia entera). Es un cambio de API pública, no un
215 // ajuste interno, y por eso se registra acá en vez de hacerse de apuro.
216 {
217 let textos = etiquetas(500);
218 let t0 = Instant::now();
219 let mut sumidero = 0usize;
220 for texto in &textos {
221 // Exactamente las alocaciones que hace la construcción de la clave.
222 let k_text = texto.to_string();
223 let k_family: Option<String> = None;
224 let k_features = String::new();
225 let k_variations = String::new();
226 sumidero += k_text.len() + k_features.len() + k_variations.len() + k_family.map_or(0, |f| f.len());
227 }
228 let ms_clave = t0.elapsed().as_secs_f64() * 1000.0;
229 let tibio_500: f64 = {
230 let mut m: Vec<f64> = (0..5).map(|_| pasada(&mut ts, &etiquetas(500))).collect();
231 m.sort_by(|a, b| a.partial_cmp(b).unwrap());
232 m[m.len() / 2]
233 };
234 println!("=============================================================");
235 println!(" ¿De qué está hecho el camino tibio? (500 etiquetas)");
236 println!("=============================================================");
237 println!(" tibio completo : {tibio_500:7.3} ms");
238 println!(" sólo armar las claves : {ms_clave:7.3} ms ({:.0}% del tibio)",
239 100.0 * ms_clave / tibio_500);
240 println!(" resto (lookup + clone) : {:7.3} ms", tibio_500 - ms_clave);
241 println!(" (sumidero anti-optimizador: {sumidero})");
242 println!();
243 }
244
245 println!("=============================================================");
246 println!(" Recordatorio de la tabla de W4 (con la que hay que comparar):");
247 println!(" IR de escena, SIN contar texto : 77 fps @500 · 164 @200 · 543 @50");
248 println!(" Jaula pura (rasterizar dentro): 18 fps @500 · 27 @200 · 57 @50");
249 println!(" Kernel puro (wire-view) : 685 fps @500, pero sólo ~15-18");
250 println!(" de los ~41 entries del MANUAL");
251 println!("=============================================================");
252}