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//! **¿El texto por GPU-directo sale más barato que por vello?**
2//!
3//! El atlas de glifos (`llimphi-glifos` + `GpuBatch::add_glyph`, 2026-08-05)
4//! está certificado como **correcto**: `llimphi-test/tests/texto_gpu_directo.rs`
5//! pone los dos caminos sobre el mismo `parley::Layout` y mide `Δ64:0`. Lo que
6//! no estaba medido es si **vale la pena** — y sin ese número el atlas sería
7//! justo la enfermedad que la Iniciativa #4 del `PLAN-MOTOR-PROFUNDO` viene a
8//! curar: algo construido que nadie usa porque nadie sabe si conviene.
9//!
10//! El argumento a favor era «80 contornos rellenos contra 80 instancias de
11//! 48 B». Eso es un argumento, no una medición.
12//!
13//! ## Qué se mide y qué NO
14//!
15//! El **shaping es el mismo para los dos** —los dos consumen un `parley::Layout`
16//! ya resuelto— así que queda fuera del bucle medido a propósito: meterlo sería
17//! sumarle a los dos el mismo número y achatar el factor.
18//!
19//! De cada camino se miden dos cosas por separado, y la distinción importa:
20//!
21//! - **preparar** — construir la escena de vello (un `draw_glyphs` por run) o
22//! armar los quads (una búsqueda en el atlas por glifo). Es CPU pura, así que
23//! este número **vale en cualquier máquina**, incluso en llvmpipe.
24//! - **total** — preparar + rasterizar + esperar a la GPU. En una máquina sin
25//! GPU real esto mide una CPU rasterizando, y **no decide nada** sobre el
26//! comportamiento en hardware. Se imprime igual, marcado.
27//!
28//! El atlas se llena **antes** del bucle medido y la textura se sube una vez:
29//! lo que se mide es el régimen estable, que es donde vive una app. El primer
30//! frame de un atlas frío paga la rasterización de cada glifo y no es
31//! representativo de nada.
32
33use std::time::Instant;
34
35use llimphi_glifos::{quads_de_layout, AtlasGlifos, QuadGlifo};
36use llimphi_hal::{wgpu, Hal};
37use llimphi_raster::gpu_atlas::AtlasTextura;
38use llimphi_raster::peniko::{color::palette, Color};
39use llimphi_raster::{vello, GpuBatch, GpuPipelines};
40
41/// La frase de prueba. 56 caracteres con acentos y dígitos: un repertorio
42/// realista, no veinte aes que compartirían la misma entrada del atlas y
43/// harían ver el caché mucho mejor de lo que es.
44const FRASE: &str = "El zorro marrón salta sobre el perro perezoso 0123456789";
45
46const TAM: f32 = 11.0;
47const ALTO_LINEA: f32 = 12.0;
48/// Cuántos renglones entran en una columna sin salirse del target.
49const POR_COLUMNA: u32 = 84;
50const ANCHO_COLUMNA: f32 = 330.0;
51
52/// Cuántos renglones por corrida. El tope son 3 columnas × 84 = 252, o sea
53/// ~14.000 glifos: más que eso empezaría a pintar fuera del target, y un glifo
54/// que el rasterizador descarta por estar afuera haría mentir al número.
55const RENGLONES: &[u32] = &[10, 50, 150, 252];
56
57/// Muestras del bench de texto, más que las del resto del binario a propósito
58/// (ver el comentario en `main`). Un cuadro de texto cuesta pocos ms, así que
59/// el ruido de la máquina se le nota mucho más que a un cuadro de 1 M de rects.
60pub const WARMUP: usize = 15;
61pub const MEDIDOS: usize = 60;
62
63pub struct FilaTexto {
64 pub renglones: u32,
65 pub glifos: u32,
66 pub vello_prep_ms: f64,
67 pub vello_total_ms: f64,
68 pub atlas_prep_ms: f64,
69 pub atlas_total_ms: f64,
70}
71
72impl FilaTexto {
73 pub fn factor_prep(&self) -> f64 {
74 self.vello_prep_ms / self.atlas_prep_ms.max(1e-9)
75 }
76 pub fn factor_total(&self) -> f64 {
77 self.vello_total_ms / self.atlas_total_ms.max(1e-9)
78 }
79}
80
81fn origen(i: u32) -> (f32, f32) {
82 let col = i / POR_COLUMNA;
83 let fila = i % POR_COLUMNA;
84 (8.0 + col as f32 * ANCHO_COLUMNA, 12.0 + fila as f32 * ALTO_LINEA)
85}
86
87/// Mediana de una muestra. Copiada de `main` a propósito: no vale la pena
88/// exportarla, y una función de tres líneas duplicada se lee mejor que un
89/// `pub(crate)` que obliga a saltar de archivo.
90fn mediana(v: &mut [f64]) -> f64 {
91 v.sort_by(|a, b| a.partial_cmp(b).unwrap());
92 let n = v.len();
93 if n == 0 {
94 0.0
95 } else if n % 2 == 1 {
96 v[n / 2]
97 } else {
98 (v[n / 2 - 1] + v[n / 2]) / 2.0
99 }
100}
101
102pub fn correr(
103 hal: &Hal,
104 renderer: &mut vello::Renderer,
105 pipelines: &GpuPipelines,
106 view: &wgpu::TextureView,
107 w: u32,
108 h: u32,
109 warmup: usize,
110 medidos: usize,
111) -> Vec<FilaTexto> {
112 // El layout, UNA vez. Los dos caminos lo comparten: el shaping no está en
113 // discusión y meterlo adentro del bucle le sumaría lo mismo a cada uno.
114 let mut ts = llimphi_text::Typesetter::new();
115 let color = Color::from_rgba8(235, 235, 245, 255);
116 let bloque = llimphi_text::TextBlock::simple(FRASE, TAM, color, (0.0, 0.0));
117 let layout = llimphi_text::layout_block(&mut ts, &bloque);
118
119 let mut filas = Vec::new();
120 for &n in RENGLONES {
121 // ── Atlas caliente ANTES de medir ──────────────────────────────
122 // Un atlas frío paga la rasterización de cada glifo en el primer
123 // frame. Eso es real y hay que decirlo, pero no es lo que hace una
124 // app cuadro a cuadro, que es lo que este bench compara.
125 let mut atlas = AtlasGlifos::new(1024);
126 let mut quads: Vec<QuadGlifo> = Vec::new();
127 for i in 0..n {
128 quads_de_layout(&mut atlas, &layout, origen(i), &mut quads);
129 }
130 let glifos = quads.len() as u32;
131 let mut tex = AtlasTextura::new(&hal.device, pipelines, atlas.lado());
132 if let Some(s) = atlas.tomar_sucio() {
133 tex.subir(&hal.queue, atlas.pixeles(), (s.x, s.y, s.ancho, s.alto));
134 }
135
136 // ── Camino vello ───────────────────────────────────────────────
137 let mut scene = vello::Scene::new();
138 let (mut prep_v, mut total_v) = (Vec::new(), Vec::new());
139 for frame in 0..(warmup + medidos) {
140 let t0 = Instant::now();
141 scene.reset();
142 for i in 0..n {
143 let (x, y) = origen(i);
144 llimphi_text::draw_layout(&mut scene, &layout, color, (x as f64, y as f64));
145 }
146 let t_prep = t0.elapsed().as_secs_f64() * 1000.0;
147 renderer
148 .render_to_texture(
149 &hal.device,
150 &hal.queue,
151 &scene,
152 view,
153 &vello::RenderParams {
154 base_color: palette::css::BLACK,
155 width: w,
156 height: h,
157 antialiasing_method: vello::AaConfig::Area,
158 },
159 )
160 .expect("vello render");
161 hal.device
162 .poll(wgpu::PollType::wait_indefinitely())
163 .expect("poll");
164 let t_total = t0.elapsed().as_secs_f64() * 1000.0;
165 if frame >= warmup {
166 prep_v.push(t_prep);
167 total_v.push(t_total);
168 }
169 }
170
171 // ── Camino atlas ───────────────────────────────────────────────
172 let (mut prep_a, mut total_a) = (Vec::new(), Vec::new());
173 let mut buf: Vec<QuadGlifo> = Vec::new();
174 for frame in 0..(warmup + medidos) {
175 let t0 = Instant::now();
176 let mut batch = GpuBatch::new(pipelines);
177 batch.atlas(&tex);
178 // Se rearman los quads cada cuadro, igual que vello rearma su
179 // escena: es la comparación honesta. Guardarlos entre frames sería
180 // una optimización que el otro camino no tiene.
181 buf.clear();
182 for i in 0..n {
183 quads_de_layout(&mut atlas, &layout, origen(i), &mut buf);
184 }
185 for q in &buf {
186 batch.add_glyph(q.x, q.y, q.w, q.h, (q.u0, q.v0, q.u1, q.v1), color);
187 }
188 let t_prep = t0.elapsed().as_secs_f64() * 1000.0;
189 let mut enc = hal
190 .device
191 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
192 label: Some("bench-texto-enc"),
193 });
194 batch.flush(
195 &hal.device,
196 &hal.queue,
197 &mut enc,
198 view,
199 (w as f32, h as f32),
200 wgpu::LoadOp::Clear(wgpu::Color::BLACK),
201 );
202 hal.queue.submit(std::iter::once(enc.finish()));
203 hal.device
204 .poll(wgpu::PollType::wait_indefinitely())
205 .expect("poll");
206 let t_total = t0.elapsed().as_secs_f64() * 1000.0;
207 if frame >= warmup {
208 prep_a.push(t_prep);
209 total_a.push(t_total);
210 }
211 }
212
213 // Contraprueba de que el atlas no se llenó: si hubiera descartado
214 // glifos, estaría midiendo un dibujo incompleto contra uno entero.
215 assert_eq!(
216 atlas.fallos, 0,
217 "el atlas descartó glifos: la comparación no es entre dos dibujos iguales"
218 );
219
220 filas.push(FilaTexto {
221 renglones: n,
222 glifos,
223 vello_prep_ms: mediana(&mut prep_v),
224 vello_total_ms: mediana(&mut total_v),
225 atlas_prep_ms: mediana(&mut prep_a),
226 atlas_total_ms: mediana(&mut total_a),
227 });
228 }
229 filas
230}
231
232pub fn imprimir(filas: &[FilaTexto], gpu_real: bool) {
233 println!("## Texto: vello contra el atlas de glifos");
234 println!();
235 println!(
236 "Frase de {} caracteres a {TAM} px, repetida N veces. El **shaping está \
237 fuera del bucle**: los dos caminos consumen el mismo `parley::Layout`, \
238 así que meterlo sumaría lo mismo a cada uno. El atlas se llena antes de \
239 medir — se compara el régimen estable, no el primer cuadro.",
240 FRASE.chars().count()
241 );
242 println!();
243 println!("| renglones | glifos | vello prep ms | atlas prep ms | prep × | vello total ms | atlas total ms | total × |");
244 println!("|---:|---:|---:|---:|---:|---:|---:|---:|");
245 for f in filas {
246 println!(
247 "| {} | {} | {:.3} | {:.3} | {:.2}× | {:.2} | {:.2} | {:.2}× |",
248 f.renglones,
249 f.glifos,
250 f.vello_prep_ms,
251 f.atlas_prep_ms,
252 f.factor_prep(),
253 f.vello_total_ms,
254 f.atlas_total_ms,
255 f.factor_total()
256 );
257 }
258 println!();
259 println!("**Cómo leer esto.** La columna `prep` es CPU pura —armar la escena \
260 contra armar los quads— y vale en cualquier máquina.");
261 if gpu_real {
262 println!();
263 println!("La columna `total` incluye el rasterizado y la espera a la GPU, \
264 y en esta corrida hay GPU real: el número decide.");
265 } else {
266 println!();
267 println!("La columna `total` incluye el rasterizado, pero **esta corrida \
268 no tiene GPU real** (adapter de software): ahí se está midiendo \
269 una CPU rasterizando, no una GPU. Ese número NO decide nada \
270 sobre hardware — hay que volver a correr esto en metal.");
271 }
272}