tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ main llimphi-gpu-bench/src/scissor.rs Rust · 216 siq'ikuna · 7.8 KB Ñawpa willay Pi imata llamkarqan ch'uya

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

1//! **¿Cuánto ahorra de verdad el `scissor` del GPU-directo?**
2//!
3//! `GpuBatch::scissor` es el primitivo de damage / partial-present: cuando sólo
4//! cambió una región chica —un cursor que parpadea, un spinner, un nodo que se
5//! anima— el caller declara el rect sucio y el `flush` no toca el resto.
6//!
7//! El número que había en `PLAN-MOTOR-PROFUNDO.md` (14,8 % antes del arreglo,
8//! 97,9 % después) salió de una medición **ad-hoc que nunca quedó en el repo**.
9//! O sea que no se podía repetir, ni en otra máquina ni después de un cambio.
10//! Esto lo vuelve un bench: mismo escenario, una orden.
11//!
12//! ## Por qué el número puede cambiar con el driver
13//!
14//! El arreglo de 2026-08-05 fue dimensionar las texturas intermedias (MSAA 4× +
15//! resolve) **al rect sucio** en vez de al viewport. Antes se rasterizaba a
16//! pantalla completa y se recortaba con `set_scissor_rect`, que es estado de
17//! rasterización: no toca el `LoadOp::Clear` del attachment ni el resolve del
18//! MSAA, que son de **toda** la superficie por especificación.
19//!
20//! El sentido del cambio no depende del driver. La **magnitud** sí: una GPU con
21//! fast-clear paga menos por limpiar que una CPU rasterizando, así que el ahorro
22//! medido en llvmpipe es un techo, no una promesa de hardware. Por eso vale
23//! correrlo en metal.
24
25use std::time::Instant;
26
27use llimphi_hal::{wgpu, Hal};
28use llimphi_raster::peniko::Color;
29use llimphi_raster::{GpuBatch, GpuPipelines};
30
31/// El escenario del plan: una pantalla de laptop.
32const ANCHO: u32 = 1920;
33const ALTO: u32 = 1080;
34const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
35
36const WARMUP: usize = 20;
37const MEDIDOS: usize = 120;
38
39/// Lados del rect sucio. El primero es el cursor de 32 px del que habla el plan
40/// (0,05 % de la pantalla); el último es la pantalla entera, que existe como
41/// **control**: ahí el scissor no puede ahorrar nada, y si el bench dijera que
42/// ahorra, estaría midiendo cualquier cosa.
43const SUCIOS: &[(u32, u32)] = &[(32, 32), (128, 128), (512, 512), (ANCHO, ALTO)];
44
45pub struct FilaScissor {
46    pub ancho: u32,
47    pub alto: u32,
48    pub fraccion: f64,
49    pub sin_ms: f64,
50    pub con_ms: f64,
51}
52
53impl FilaScissor {
54    pub fn ahorro(&self) -> f64 {
55        1.0 - self.con_ms / self.sin_ms.max(1e-9)
56    }
57}
58
59/// **Mínimo, no mediana.** Y no es para que el número quede lindo: acá se mide
60/// una latencia con piso, donde todo el ruido —planificador, frecuencia,
61/// el resto del sistema— sólo puede **sumar**. La mediana de una muestra
62/// contaminada mide contaminación; el mínimo mide el trabajo.
63///
64/// Se llegó a esto midiendo: con mediana, la columna «sin scissor» —que por
65/// construcción hace siempre lo mismo, la pantalla entera— salía 1,1 / 4,0 /
66/// 0,9 / 2,3 ms en las cuatro filas. Que un número que debe ser constante
67/// varíe 4× es la firma de estar midiendo la máquina y no el código.
68fn piso(v: &[f64]) -> f64 {
69    v.iter().copied().fold(f64::INFINITY, f64::min)
70}
71
72/// Un cuadro: unas pocas primitivas dentro del rect sucio, con o sin scissor.
73///
74/// Pocas primitivas **a propósito**: lo que se mide es el costo fijo del
75/// `flush` —crear las intermedias, limpiar, resolver, componer—, no el de
76/// rasterizar. Meter un millón de rects taparía justo lo que se quiere ver.
77fn cuadro(
78    hal: &Hal,
79    pipelines: &GpuPipelines,
80    vista: &wgpu::TextureView,
81    sucio: (u32, u32, u32, u32),
82    usar_scissor: bool,
83) -> f64 {
84    let t0 = Instant::now();
85    let mut b = GpuBatch::new(pipelines);
86    if usar_scissor {
87        b.scissor(sucio.0, sucio.1, sucio.2, sucio.3);
88    }
89    // El "cursor": un rect y un disco dentro de la región sucia.
90    b.add_rect(
91        sucio.0 as f32,
92        sucio.1 as f32,
93        sucio.2 as f32,
94        sucio.3 as f32,
95        Color::from_rgba8(220, 220, 240, 255),
96    );
97    b.add_disc(
98        sucio.0 as f32 + sucio.2 as f32 * 0.5,
99        sucio.1 as f32 + sucio.3 as f32 * 0.5,
100        (sucio.2.min(sucio.3) as f32 * 0.3).max(1.0),
101        Color::from_rgba8(240, 120, 60, 255),
102    );
103    let mut enc = hal
104        .device
105        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
106            label: Some("bench-scissor-enc"),
107        });
108    b.flush(
109        &hal.device,
110        &hal.queue,
111        &mut enc,
112        vista,
113        (ANCHO as f32, ALTO as f32),
114        wgpu::LoadOp::Load,
115    );
116    hal.queue.submit(std::iter::once(enc.finish()));
117    hal.device
118        .poll(wgpu::PollType::wait_indefinitely())
119        .expect("poll");
120    t0.elapsed().as_secs_f64() * 1000.0
121}
122
123pub fn correr(hal: &Hal, pipelines: &GpuPipelines) -> Vec<FilaScissor> {
124    // Target propio: el escenario del plan es una pantalla de laptop, no el
125    // lienzo cuadrado del resto del bench.
126    let target = hal.device.create_texture(&wgpu::TextureDescriptor {
127        label: Some("bench-scissor-target"),
128        size: wgpu::Extent3d {
129            width: ANCHO,
130            height: ALTO,
131            depth_or_array_layers: 1,
132        },
133        mip_level_count: 1,
134        sample_count: 1,
135        dimension: wgpu::TextureDimension::D2,
136        format: FMT,
137        usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
138        view_formats: &[],
139    });
140    let vista = target.create_view(&wgpu::TextureViewDescriptor::default());
141
142    let mut filas = Vec::new();
143    for &(w, h) in SUCIOS {
144        let rect = (100, 100, w.min(ANCHO - 100.min(ANCHO)), h.min(ALTO));
145        let rect = (
146            rect.0.min(ANCHO.saturating_sub(w)),
147            rect.1.min(ALTO.saturating_sub(h)),
148            w,
149            h,
150        );
151        let mut sin: Vec<f64> = Vec::new();
152        let mut con: Vec<f64> = Vec::new();
153        for i in 0..(WARMUP + MEDIDOS) {
154            let a = cuadro(hal, pipelines, &vista, rect, false);
155            let b = cuadro(hal, pipelines, &vista, rect, true);
156            if i >= WARMUP {
157                sin.push(a);
158                con.push(b);
159            }
160        }
161        filas.push(FilaScissor {
162            ancho: w,
163            alto: h,
164            fraccion: (w as f64 * h as f64) / (ANCHO as f64 * ALTO as f64),
165            sin_ms: piso(&sin),
166            con_ms: piso(&con),
167        });
168    }
169    filas
170}
171
172pub fn imprimir(filas: &[FilaScissor]) {
173    println!("## Scissor: cuánto ahorra pintar sólo el rect sucio");
174    println!();
175    println!(
176        "Target {ANCHO}×{ALTO}. Cada cuadro emite **pocas** primitivas dentro de \
177         la región sucia, a propósito: lo que se mide es el costo fijo del \
178         `flush` —crear las intermedias, limpiar, resolver, componer—, no el de \
179         rasterizar. La última fila es la pantalla entera y está como **control**: \
180         ahí el scissor no puede ahorrar nada."
181    );
182    println!();
183    println!("| rect sucio | % de pantalla | sin scissor ms | con scissor ms | ahorro |");
184    println!("|---|---:|---:|---:|---:|");
185    for f in filas {
186        println!(
187            "| {}×{} | {:.3} % | {:.3} | {:.3} | {:.1} % |",
188            f.ancho,
189            f.alto,
190            f.fraccion * 100.0,
191            f.sin_ms,
192            f.con_ms,
193            f.ahorro() * 100.0
194        );
195    }
196    println!();
197    // El control se evalúa acá y no se deja a la vista de quien lee: un bench
198    // que no se juzga a sí mismo es una tabla de números bonitos.
199    if let Some(ctrl) = filas.last() {
200        if ctrl.fraccion > 0.99 {
201            if ctrl.ahorro().abs() <= 0.15 {
202                println!(
203                    "**Control OK**: a pantalla completa el ahorro es {:.1} % \
204                     (≈0, como debe ser).",
205                    ctrl.ahorro() * 100.0
206                );
207            } else {
208                println!(
209                    "**CONTROL FALLADO**: a pantalla completa el scissor \
210                     «ahorra» {:.1} %. El bench está midiendo otra cosa.",
211                    ctrl.ahorro() * 100.0
212                );
213            }
214        }
215    }
216}