tawasuyu

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Sutichaykuna

⎇ main llimphi-text/examples/medir_shaping.rs Rust · 252 siq'ikuna · 11.4 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

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}