tawasuyu

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Definiciones

⎇ main llimphi-gpu-bench/src/texto.rs Rust · 272 líneas · 11.0 KB Historia Quién tocó qué crudo

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

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}