tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

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

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

1//! `llimphi-gpu-bench` — binario standalone para validar el SDD
2//! `02_ruway/llimphi/SDD.md` §"GPU directo wgpu" en una máquina con GPU
3//! real.
4//!
5//! Hace cuatro cosas en orden y lo imprime todo a stdout en formato
6//! markdown / tabla copy-paste friendly:
7//!
8//! 1. **Header del sistema** — versión, hora, OS, GPU detectado.
9//! 2. **Info del adapter wgpu** — backend (Vulkan/Metal/DX12/GL),
10//!    device name, vendor, limits relevantes.
11//! 3. **Spike vello vs GPU directo** — para N ∈ {25K, 50K, 100K, 200K,
12//!    500K, 1M}. Mide ms/frame de cada uno y el factor. Evalúa el
13//!    criterio del SDD: ≥5× a 500K → PASA; < → ABORTAR.
14//! 4. **Escalado GPU directo solo** — para N ∈ {100K, 500K, 1M, 2M,
15//!    5M, 10M}. Mide ms/frame, fps equivalente, Mprim/s. Evalúa el
16//!    objetivo de 60 fps @ 1M.
17//! 5. **PNGs de verificación visual** — exporta 2 archivos al cwd:
18//!    `bench_vello_100k.png` y `bench_directo_100k.png`. La forma del
19//!    cielo de puntos debe coincidir entre los dos (LCG determinista).
20//!
21//! Pegar el output completo en chat para la verificación.
22//!
23//! Corre con: `cargo run -p llimphi-gpu-bench --release`.
24
25use std::fs::File;
26use std::io::{BufWriter, Write};
27use std::time::Instant;
28
29use llimphi_hal::{wgpu, Hal};
30use llimphi_raster::kurbo::{Affine, Rect};
31use llimphi_raster::peniko::{color::palette, Color, Fill};
32use llimphi_raster::{vello, GpuBatch, GpuPipelines};
33
34const W: u32 = 1024;
35const H: u32 = 1024;
36const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
37const WARMUP: usize = 5;
38const MEASURED: usize = 15;
39
40const SPIKE_SIZES: &[u32] = &[25_000, 50_000, 100_000, 200_000, 500_000, 1_000_000];
41const SCALE_SIZES: &[u32] = &[100_000, 500_000, 1_000_000, 2_000_000, 5_000_000, 10_000_000];
42
43/// Overrides via env vars (para correr en hosts limitados sin tumbar el
44/// binario). En GPU real ignorarlos y dejar los defaults.
45///
46/// - `LLIMPHI_BENCH_SPIKE_MAX=N` — recorta SPIKE_SIZES a los ≤ N.
47/// - `LLIMPHI_BENCH_SCALE_MAX=N` — idem SCALE_SIZES.
48/// - `LLIMPHI_BENCH_SKIP_VELLO=1` — saltea totalmente la columna vello
49///   (útil si vello revienta con SIGSEGV en este host).
50fn spike_sizes() -> Vec<u32> {
51    let max = std::env::var("LLIMPHI_BENCH_SPIKE_MAX")
52        .ok()
53        .and_then(|v| v.parse::<u32>().ok())
54        .unwrap_or(u32::MAX);
55    SPIKE_SIZES.iter().copied().filter(|&n| n <= max).collect()
56}
57
58fn scale_sizes() -> Vec<u32> {
59    let max = std::env::var("LLIMPHI_BENCH_SCALE_MAX")
60        .ok()
61        .and_then(|v| v.parse::<u32>().ok())
62        .unwrap_or(u32::MAX);
63    SCALE_SIZES.iter().copied().filter(|&n| n <= max).collect()
64}
65
66fn skip_vello() -> bool {
67    std::env::var("LLIMPHI_BENCH_SKIP_VELLO").ok().as_deref() == Some("1")
68}
69
70fn main() {
71    print_header();
72    let hal = pollster::block_on(Hal::new(None)).expect("hal");
73    print_adapter(&hal);
74
75    let (target, view) = make_target(&hal.device);
76
77    let pipelines = GpuPipelines::new(&hal.device, FMT);
78    let mut vello_renderer = vello::Renderer::new(
79        &hal.device,
80        vello::RendererOptions {
81            use_cpu: false,
82            antialiasing_support: vello::AaSupport {
83                area: true,
84                msaa8: false,
85                msaa16: false,
86            },
87            num_init_threads: None,
88            pipeline_cache: None,
89        },
90    )
91    .expect("vello renderer");
92
93    println!("## Spike vello vs GPU directo");
94    println!();
95    println!("Target: {W}×{H} Rgba8Unorm, headless. Cada N corre {WARMUP} warmup + {MEASURED} medidos, reporta mediana.");
96    println!();
97    println!("| N | vello ms | directo ms | factor | nota |");
98    println!("|---:|---:|---:|---:|---|");
99    let mut spike_rows: Vec<SpikeRow> = Vec::new();
100    let skip_v = skip_vello();
101    for n in spike_sizes() {
102        let row = bench_spike(&hal, &mut vello_renderer, &pipelines, &view, n, skip_v);
103        let note = if row.vello_crashed {
104            "vello SIGSEGV/error"
105        } else if let Some(f) = row.factor {
106            if f >= 5.0 { "≥5×" } else { "<5×" }
107        } else {
108            "-"
109        };
110        let vello_str = if row.vello_crashed {
111            "—".to_string()
112        } else {
113            format!("{:.2}", row.vello_ms.unwrap_or(0.0))
114        };
115        let factor_str = match row.factor {
116            Some(f) => format!("{:.2}×", f),
117            None => "—".to_string(),
118        };
119        println!(
120            "| {} | {} | {:.2} | {} | {} |",
121            fmt_int(n),
122            vello_str,
123            row.directo_ms,
124            factor_str,
125            note
126        );
127        let _ = std::io::stdout().flush();
128        spike_rows.push(row);
129    }
130    println!();
131    print_spike_verdict(&spike_rows);
132
133    println!("## Escalado GPU directo");
134    println!();
135    println!("API real (`GpuPipelines` + `GpuBatch::add_rect`). Sólo se mide el lado GPU directo — vello no llega aquí.");
136    println!();
137    println!("| N | ms / frame | fps (1000/ms) | Mprim/s |");
138    println!("|---:|---:|---:|---:|");
139    let mut scale_rows: Vec<ScaleRow> = Vec::new();
140    for n in scale_sizes() {
141        let ms = bench_directo(&hal, &pipelines, &view, n);
142        let fps = 1000.0 / ms;
143        let mps = (n as f64 / 1_000_000.0) / (ms / 1000.0);
144        println!(
145            "| {} | {:.2} | {:.1} | {:.2} |",
146            fmt_int(n),
147            ms,
148            fps,
149            mps
150        );
151        let _ = std::io::stdout().flush();
152        scale_rows.push(ScaleRow { n, ms, fps, mps });
153    }
154    println!();
155    print_scale_verdict(&scale_rows);
156
157    // ----------------------------------------------------------------
158    // Variantes persistentes: el rebuild del batch/scene por frame es
159    // el peor caso. En apps reales (cosmos starfield Gaia, tinkuy
160    // particles iniciales, nakui viewport estático) los datos no
161    // cambian por frame — se uploadean UNA vez y el bucle solo redraw.
162    // Estos benches lo miden.
163    // ----------------------------------------------------------------
164    println!("## Persistente — datos fijos, sólo redraw por frame");
165    println!();
166    println!("Setup (LCG + write_buffer / Scene fill) fuera de la medición; el bucle medido sólo emite render_pass + draw + submit + wait.");
167    println!();
168    println!("### vello (Scene reutilizada sin reset)");
169    println!();
170    println!("| N | ms / frame | fps (1000/ms) |");
171    println!("|---:|---:|---:|");
172    let mut vello_persist_rows: Vec<(u32, f64)> = Vec::new();
173    let skip_v = skip_vello();
174    for n in scale_sizes() {
175        if skip_v {
176            println!("| {} | skipped | — |", fmt_int(n));
177            continue;
178        }
179        let attempt = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
180            bench_vello_persistent(&hal, &mut vello_renderer, &view, n)
181        }));
182        match attempt {
183            Ok(ms) => {
184                let fps = 1000.0 / ms;
185                println!("| {} | {:.2} | {:.1} |", fmt_int(n), ms, fps);
186                let _ = std::io::stdout().flush();
187                vello_persist_rows.push((n, ms));
188            }
189            Err(_) => {
190                println!("| {} | crash | — |", fmt_int(n));
191            }
192        }
193    }
194    println!();
195    println!("### GPU directo (buffer + bind group persistentes)");
196    println!();
197    println!("| N | ms / frame | fps (1000/ms) | Mprim/s |");
198    println!("|---:|---:|---:|---:|");
199    let mut directo_persist_rows: Vec<ScaleRow> = Vec::new();
200    for n in scale_sizes() {
201        let ms = bench_directo_persistent(&hal, &pipelines, &view, n);
202        let fps = 1000.0 / ms;
203        let mps = (n as f64 / 1_000_000.0) / (ms / 1000.0);
204        println!("| {} | {:.2} | {:.1} | {:.2} |", fmt_int(n), ms, fps, mps);
205        let _ = std::io::stdout().flush();
206        directo_persist_rows.push(ScaleRow { n, ms, fps, mps });
207    }
208    println!();
209    print_persistent_verdict(&directo_persist_rows, &vello_persist_rows);
210
211    println!("## Validación visual");
212    println!();
213    let png_vello = "bench_vello_100k.png";
214    let png_directo = "bench_directo_100k.png";
215    if let Err(e) = export_vello_png(&hal, &mut vello_renderer, &target, &view, 100_000, png_vello)
216    {
217        println!("vello PNG fallo: {e}");
218    } else {
219        println!("- vello 100K   → `{}` ({W}×{H})", png_vello);
220    }
221    if let Err(e) =
222        export_directo_png(&hal, &pipelines, &target, &view, 100_000, png_directo)
223    {
224        println!("directo PNG fallo: {e}");
225    } else {
226        println!("- directo 100K → `{}` ({W}×{H})", png_directo);
227    }
228    println!();
229    println!("Las dos imágenes deben mostrar la misma constelación de puntos (LCG determinista).");
230    println!("Mirar en visor: si vello tiene halo AA suave y directo tiene pixeles hard-edged, todo bien.");
231    println!();
232
233    println!("## Resumen");
234    println!();
235    print_summary(
236        &spike_rows,
237        &scale_rows,
238        &directo_persist_rows,
239        &vello_persist_rows,
240    );
241}
242
243// ============================================================
244// IO / header
245// ============================================================
246
247fn print_header() {
248    println!("# llimphi-gpu-bench");
249    println!();
250    println!("Validación de Fase 0 del SDD `02_ruway/llimphi/SDD.md` §\"GPU directo wgpu\".");
251    println!("Criterio: factor ≥ 5× a 500K Y ≥ 60 fps @ 1M en GPU mid (Radeon 5500M, Iris Xe).");
252    println!();
253    println!("- crate version: {}", env!("CARGO_PKG_VERSION"));
254    println!("- host OS: {}", std::env::consts::OS);
255    println!("- host arch: {}", std::env::consts::ARCH);
256    println!();
257}
258
259fn print_adapter(hal: &Hal) {
260    let info = hal.adapter.get_info();
261    let limits = hal.adapter.limits();
262    println!("## Adapter wgpu");
263    println!();
264    println!("- backend: `{:?}`", info.backend);
265    println!("- device name: `{}`", info.name);
266    println!("- vendor: `0x{:04x}`", info.vendor);
267    println!("- device id: `0x{:04x}`", info.device);
268    println!("- device type: `{:?}`", info.device_type);
269    println!("- driver: `{}`", info.driver);
270    println!("- driver info: `{}`", info.driver_info);
271    println!();
272    println!("Limits relevantes:");
273    println!();
274    println!("- max texture 2D: {}", limits.max_texture_dimension_2d);
275    println!("- max buffer size: {} MB", limits.max_buffer_size / (1024 * 1024));
276    println!("- max storage buffer binding: {} MB", limits.max_storage_buffer_binding_size / (1024 * 1024));
277    println!();
278    let is_software = matches!(
279        info.device_type,
280        wgpu::DeviceType::Cpu
281    ) || info.driver.to_lowercase().contains("llvmpipe")
282        || info.driver.to_lowercase().contains("software")
283        || info.name.to_lowercase().contains("llvmpipe")
284        || info.name.to_lowercase().contains("swiftshader");
285    if is_software {
286        println!("⚠️  Adapter parece software (`{}`). Los números no reflejan GPU real.", info.name);
287        println!();
288    }
289}
290
291fn fmt_int(n: u32) -> String {
292    let s = n.to_string();
293    let mut out = String::new();
294    for (i, c) in s.chars().rev().enumerate() {
295        if i > 0 && i % 3 == 0 {
296            out.push('_');
297        }
298        out.push(c);
299    }
300    out.chars().rev().collect()
301}
302
303// ============================================================
304// Benches
305// ============================================================
306
307struct SpikeRow {
308    n: u32,
309    vello_ms: Option<f64>,
310    vello_crashed: bool,
311    directo_ms: f64,
312    factor: Option<f64>,
313}
314
315struct ScaleRow {
316    n: u32,
317    ms: f64,
318    fps: f64,
319    mps: f64,
320}
321
322fn bench_spike(
323    hal: &Hal,
324    vello_renderer: &mut vello::Renderer,
325    pipelines: &GpuPipelines,
326    view: &wgpu::TextureView,
327    n: u32,
328    skip_vello: bool,
329) -> SpikeRow {
330    let directo_ms = bench_directo(hal, pipelines, view, n);
331    if skip_vello {
332        return SpikeRow {
333            n,
334            vello_ms: None,
335            vello_crashed: true, // tratamos "skipped" como "no llegó"
336            directo_ms,
337            factor: None,
338        };
339    }
340    // catch_unwind sólo atrapa panics, no SIGSEGV. En vello pre-200K
341    // este path debería ser suficiente; si el binario muere igual,
342    // re-correr con `LLIMPHI_BENCH_SKIP_VELLO=1`.
343    let vello_attempt = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
344        bench_vello(hal, vello_renderer, view, n)
345    }));
346    match vello_attempt {
347        Ok(ms) => {
348            let factor = ms / directo_ms;
349            SpikeRow {
350                n,
351                vello_ms: Some(ms),
352                vello_crashed: false,
353                directo_ms,
354                factor: Some(factor),
355            }
356        }
357        Err(_) => SpikeRow {
358            n,
359            vello_ms: None,
360            vello_crashed: true,
361            directo_ms,
362            factor: None,
363        },
364    }
365}
366
367fn bench_vello(
368    hal: &Hal,
369    renderer: &mut vello::Renderer,
370    view: &wgpu::TextureView,
371    n: u32,
372) -> f64 {
373    let mut scene = vello::Scene::new();
374    let mut samples: Vec<f64> = Vec::with_capacity(MEASURED);
375    for frame in 0..(WARMUP + MEASURED) {
376        let t0 = Instant::now();
377        scene.reset();
378        let mut state: u32 = 0x1234_5678;
379        for _ in 0..n {
380            let (x, y, rgba) = lcg_point(&mut state);
381            let r = (rgba & 0xFF) as u8;
382            let g = ((rgba >> 8) & 0xFF) as u8;
383            let b = ((rgba >> 16) & 0xFF) as u8;
384            let a = ((rgba >> 24) & 0xFF) as u8;
385            let xf = x as f64;
386            let yf = y as f64;
387            scene.fill(
388                Fill::NonZero,
389                Affine::IDENTITY,
390                Color::from_rgba8(r, g, b, a),
391                None,
392                &Rect::new(xf, yf, xf + POINT_PX as f64, yf + POINT_PX as f64),
393            );
394        }
395        renderer
396            .render_to_texture(
397                &hal.device,
398                &hal.queue,
399                &scene,
400                view,
401                &vello::RenderParams {
402                    base_color: palette::css::BLACK,
403                    width: W,
404                    height: H,
405                    antialiasing_method: vello::AaConfig::Area,
406                },
407            )
408            .expect("vello render");
409        hal.device.poll(wgpu::PollType::wait_indefinitely());
410        let dt = t0.elapsed().as_secs_f64() * 1000.0;
411        if frame >= WARMUP {
412            samples.push(dt);
413        }
414    }
415    median(&mut samples)
416}
417
418fn bench_directo(
419    hal: &Hal,
420    pipelines: &GpuPipelines,
421    view: &wgpu::TextureView,
422    n: u32,
423) -> f64 {
424    let mut samples: Vec<f64> = Vec::with_capacity(MEASURED);
425    for frame in 0..(WARMUP + MEASURED) {
426        let t0 = Instant::now();
427        let mut batch = GpuBatch::new(pipelines);
428        let mut state: u32 = 0x1234_5678;
429        for _ in 0..n {
430            let (x, y, rgba) = lcg_point(&mut state);
431            let r = (rgba & 0xFF) as u8;
432            let g = ((rgba >> 8) & 0xFF) as u8;
433            let b = ((rgba >> 16) & 0xFF) as u8;
434            let a = ((rgba >> 24) & 0xFF) as u8;
435            batch.add_rect(x, y, POINT_PX, POINT_PX, Color::from_rgba8(r, g, b, a));
436        }
437        let mut encoder = hal.device.create_command_encoder(
438            &wgpu::CommandEncoderDescriptor {
439                label: Some("bench-directo-enc"),
440            },
441        );
442        batch.flush(
443            &hal.device,
444            &hal.queue,
445            &mut encoder,
446            view,
447            (W as f32, H as f32),
448            wgpu::LoadOp::Clear(wgpu::Color::BLACK),
449        );
450        hal.queue.submit(std::iter::once(encoder.finish()));
451        hal.device.poll(wgpu::PollType::wait_indefinitely());
452        let dt = t0.elapsed().as_secs_f64() * 1000.0;
453        if frame >= WARMUP {
454            samples.push(dt);
455        }
456    }
457    median(&mut samples)
458}
459
460/// Vello persistente: la Scene se construye UNA vez (fill N rects) y
461/// el bucle medido sólo invoca `render_to_texture`. Sin `scene.reset()`.
462fn bench_vello_persistent(
463    hal: &Hal,
464    renderer: &mut vello::Renderer,
465    view: &wgpu::TextureView,
466    n: u32,
467) -> f64 {
468    let mut scene = vello::Scene::new();
469    scene.reset();
470    let mut state: u32 = 0x1234_5678;
471    for _ in 0..n {
472        let (x, y, rgba) = lcg_point(&mut state);
473        let r = (rgba & 0xFF) as u8;
474        let g = ((rgba >> 8) & 0xFF) as u8;
475        let b = ((rgba >> 16) & 0xFF) as u8;
476        let a = ((rgba >> 24) & 0xFF) as u8;
477        let xf = x as f64;
478        let yf = y as f64;
479        scene.fill(
480            Fill::NonZero,
481            Affine::IDENTITY,
482            Color::from_rgba8(r, g, b, a),
483            None,
484            &Rect::new(xf, yf, xf + POINT_PX as f64, yf + POINT_PX as f64),
485        );
486    }
487    let mut samples: Vec<f64> = Vec::with_capacity(MEASURED);
488    for frame in 0..(WARMUP + MEASURED) {
489        let t0 = Instant::now();
490        renderer
491            .render_to_texture(
492                &hal.device,
493                &hal.queue,
494                &scene,
495                view,
496                &vello::RenderParams {
497                    base_color: palette::css::BLACK,
498                    width: W,
499                    height: H,
500                    antialiasing_method: vello::AaConfig::Area,
501                },
502            )
503            .expect("vello render");
504        hal.device.poll(wgpu::PollType::wait_indefinitely());
505        let dt = t0.elapsed().as_secs_f64() * 1000.0;
506        if frame >= WARMUP {
507            samples.push(dt);
508        }
509    }
510    median(&mut samples)
511}
512
513/// GPU directo persistente: instance buffer + uniform buffer + bind
514/// group se construyen UNA vez. Bucle medido sólo abre render_pass,
515/// hace `draw(0..6, 0..n)` y submit.
516///
517/// Replica el layout que pinta `GpuBatch::add_rect` por debajo
518/// (instance stride 20 B = [x:f32, y:f32, w:f32, h:f32, rgba:u32]),
519/// usando el `rects` pipeline + `bind_layout` expuestos por
520/// `GpuPipelines`.
521fn bench_directo_persistent(
522    hal: &Hal,
523    pipelines: &GpuPipelines,
524    view: &wgpu::TextureView,
525    n: u32,
526) -> f64 {
527    // Empaquetar instancias UNA vez.
528    let mut bytes = Vec::with_capacity(n as usize * 20);
529    let mut state: u32 = 0x1234_5678;
530    for _ in 0..n {
531        let (x, y, rgba) = lcg_point(&mut state);
532        bytes.extend_from_slice(&x.to_ne_bytes());
533        bytes.extend_from_slice(&y.to_ne_bytes());
534        bytes.extend_from_slice(&POINT_PX.to_ne_bytes());
535        bytes.extend_from_slice(&POINT_PX.to_ne_bytes());
536        bytes.extend_from_slice(&rgba.to_ne_bytes());
537    }
538    let inst_buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
539        label: Some("persist-rects"),
540        size: bytes.len() as u64,
541        usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
542        mapped_at_creation: false,
543    });
544    hal.queue.write_buffer(&inst_buf, 0, &bytes);
545
546    // Uniforms (viewport + line_width).
547    let u_data: [f32; 4] = [W as f32, H as f32, 1.0, 0.0];
548    let mut u_bytes = Vec::with_capacity(16);
549    for v in u_data {
550        u_bytes.extend_from_slice(&v.to_ne_bytes());
551    }
552    let uniforms = hal.device.create_buffer(&wgpu::BufferDescriptor {
553        label: Some("persist-uniforms"),
554        size: 16,
555        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
556        mapped_at_creation: false,
557    });
558    hal.queue.write_buffer(&uniforms, 0, &u_bytes);
559
560    let bind_group = hal.device.create_bind_group(&wgpu::BindGroupDescriptor {
561        label: Some("persist-bg"),
562        layout: &pipelines.bind_layout,
563        entries: &[wgpu::BindGroupEntry {
564            binding: 0,
565            resource: uniforms.as_entire_binding(),
566        }],
567    });
568
569    // Asegurar que toda la escritura previa esté en la GPU antes de
570    // empezar a medir frames — si no, el primer frame paga el upload.
571    hal.queue.submit(std::iter::empty::<wgpu::CommandBuffer>());
572    hal.device.poll(wgpu::PollType::wait_indefinitely());
573
574    let mut samples: Vec<f64> = Vec::with_capacity(MEASURED);
575    for frame in 0..(WARMUP + MEASURED) {
576        let t0 = Instant::now();
577        let mut encoder = hal.device.create_command_encoder(
578            &wgpu::CommandEncoderDescriptor {
579                label: Some("persist-enc"),
580            },
581        );
582        {
583            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
584                label: Some("persist-pass"),
585                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
586                    view,
587                    resolve_target: None,
588                    depth_slice: None,
589                    ops: wgpu::Operations {
590                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
591                        store: wgpu::StoreOp::Store,
592                    },
593                })],
594                depth_stencil_attachment: None,
595                timestamp_writes: None,
596                occlusion_query_set: None,
597            });
598            pass.set_pipeline(&pipelines.rects);
599            pass.set_bind_group(0, &bind_group, &[]);
600            pass.set_vertex_buffer(0, inst_buf.slice(..));
601            pass.draw(0..6, 0..n);
602        }
603        hal.queue.submit(std::iter::once(encoder.finish()));
604        hal.device.poll(wgpu::PollType::wait_indefinitely());
605        let dt = t0.elapsed().as_secs_f64() * 1000.0;
606        if frame >= WARMUP {
607            samples.push(dt);
608        }
609    }
610    median(&mut samples)
611}
612
613fn lcg_point(state: &mut u32) -> (f32, f32, u32) {
614    *state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
615    let x = (*state % W) as f32;
616    *state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
617    let y = (*state % H) as f32;
618    *state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
619    // Colores: piso 128 por canal para que las PNGs de verificación
620    // se vean (sin esto el LCG produce muchos negros casi puros, y
621    // los puntos quedan invisibles en pantalla aunque estén pintados).
622    let r = 128 | ((*state >> 0) & 0x7F) as u8;
623    let g = 128 | ((*state >> 8) & 0x7F) as u8;
624    let b = 128 | ((*state >> 16) & 0x7F) as u8;
625    let rgba = (r as u32) | ((g as u32) << 8) | ((b as u32) << 16) | 0xFF00_0000;
626    (x, y, rgba)
627}
628
629const POINT_PX: f32 = 2.5;
630
631fn median(samples: &mut [f64]) -> f64 {
632    samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
633    samples[samples.len() / 2]
634}
635
636// ============================================================
637// Veredictos
638// ============================================================
639
640fn print_spike_verdict(rows: &[SpikeRow]) {
641    let at_500k = rows.iter().find(|r| r.n == 500_000);
642    match at_500k {
643        Some(r) if r.vello_crashed => {
644            println!("**Veredicto Fase 0:** Vello revienta antes de 500K → directo es el único path posible en ese régimen. PASA cualitativo.");
645        }
646        Some(r) => match r.factor {
647            Some(f) if f >= 5.0 => {
648                println!("**Veredicto Fase 0:** factor a 500K = {:.2}× ≥ 5 → **PASA** (criterio SDD cumplido).", f);
649            }
650            Some(f) => {
651                println!("**Veredicto Fase 0:** factor a 500K = {:.2}× < 5 → **ABORTAR** según criterio literal del SDD.", f);
652                println!("Pero ver si vello revienta a tamaños mayores — eso cambia el veredicto cualitativamente.");
653            }
654            None => {
655                println!("**Veredicto Fase 0:** sin datos para 500K (vello crashed o N no medido). Revisar tabla arriba.");
656            }
657        },
658        None => {
659            println!("**Veredicto Fase 0:** no se midió 500K en este run. Revisar tabla arriba.");
660        }
661    }
662    println!();
663}
664
665fn print_persistent_verdict(
666    directo: &[ScaleRow],
667    vello: &[(u32, f64)],
668) {
669    let d_1m = directo.iter().find(|r| r.n == 1_000_000);
670    let v_1m = vello.iter().find(|(n, _)| *n == 1_000_000);
671    match d_1m {
672        Some(r) if r.fps >= 60.0 => {
673            println!(
674                "**Veredicto persistente @ 1M:** directo {:.1} fps ≥ 60 → **PASA**.",
675                r.fps
676            );
677        }
678        Some(r) => {
679            println!(
680                "**Veredicto persistente @ 1M:** directo {:.1} fps < 60 → falla incluso sin rebuild.",
681                r.fps
682            );
683        }
684        None => println!("**Veredicto:** sin datos a 1M."),
685    }
686    if let (Some(d), Some((_, v_ms))) = (d_1m, v_1m) {
687        let factor = v_ms / d.ms;
688        println!(
689            "**Factor persistente @ 1M:** vello {:.1} ms / directo {:.1} ms = {:.2}× ({})",
690            v_ms,
691            d.ms,
692            factor,
693            if factor >= 5.0 { "≥5×" } else { "<5×" }
694        );
695    }
696    println!();
697}
698
699fn print_scale_verdict(rows: &[ScaleRow]) {
700    let at_1m = rows.iter().find(|r| r.n == 1_000_000);
701    match at_1m {
702        Some(r) if r.fps >= 60.0 => {
703            println!("**Veredicto Fase 0 (objetivo 60 fps @ 1M):** {:.1} fps ≥ 60 → **PASA**.", r.fps);
704        }
705        Some(r) => {
706            println!("**Veredicto Fase 0 (objetivo 60 fps @ 1M):** {:.1} fps < 60 → marginal. ¿Es CPU-bound el bench (write_buffer de 12-20 MB por frame)? Probar también con `mapped_at_creation` para sacar el camino más rápido.", r.fps);
707        }
708        None => {
709            println!("**Veredicto:** sin datos para 1M.");
710        }
711    }
712    println!();
713}
714
715fn print_summary(
716    spike: &[SpikeRow],
717    scale: &[ScaleRow],
718    persist_directo: &[ScaleRow],
719    persist_vello: &[(u32, f64)],
720) {
721    println!("Copiar lo que sigue al chat:");
722    println!();
723    println!("```");
724    println!("rebuild por frame — vello vs directo:");
725    for r in spike {
726        let v = match (r.vello_crashed, r.vello_ms) {
727            (true, _) => "crash".to_string(),
728            (_, Some(ms)) => format!("{:.1}ms", ms),
729            _ => "-".to_string(),
730        };
731        let f = r
732            .factor
733            .map(|x| format!("{:.2}x", x))
734            .unwrap_or_else(|| "-".to_string());
735        println!("  {:>10}  vello={:>10}  directo={:>7.1}ms  factor={}", fmt_int(r.n), v, r.directo_ms, f);
736    }
737    println!();
738    println!("rebuild por frame — escalado directo:");
739    for r in scale {
740        println!("  {:>10}  {:>7.1}ms  {:>5.1}fps  {:>5.2}Mprim/s", fmt_int(r.n), r.ms, r.fps, r.mps);
741    }
742    println!();
743    println!("persistente (datos fijos, sólo redraw):");
744    for r in persist_directo {
745        let v_ms = persist_vello
746            .iter()
747            .find(|(n, _)| *n == r.n)
748            .map(|(_, ms)| format!("{:>7.1}ms", ms))
749            .unwrap_or_else(|| "       —".to_string());
750        let factor = persist_vello
751            .iter()
752            .find(|(n, _)| *n == r.n)
753            .map(|(_, vms)| format!("factor={:.2}x", vms / r.ms))
754            .unwrap_or_else(|| "factor=  —  ".to_string());
755        println!(
756            "  {:>10}  vello={}  directo={:>7.1}ms  {}  {:>5.1}fps  {:>5.2}Mprim/s",
757            fmt_int(r.n),
758            v_ms,
759            r.ms,
760            factor,
761            r.fps,
762            r.mps,
763        );
764    }
765    println!("```");
766}
767
768// ============================================================
769// Textura destino + PNG export
770// ============================================================
771
772fn make_target(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView) {
773    let tex = device.create_texture(&wgpu::TextureDescriptor {
774        label: Some("bench-target"),
775        size: wgpu::Extent3d {
776            width: W,
777            height: H,
778            depth_or_array_layers: 1,
779        },
780        mip_level_count: 1,
781        sample_count: 1,
782        dimension: wgpu::TextureDimension::D2,
783        format: FMT,
784        // RENDER_ATTACHMENT para el directo, STORAGE_BINDING para vello,
785        // TEXTURE_BINDING + COPY_SRC para poder leer (PNG export).
786        usage: wgpu::TextureUsages::RENDER_ATTACHMENT
787            | wgpu::TextureUsages::STORAGE_BINDING
788            | wgpu::TextureUsages::TEXTURE_BINDING
789            | wgpu::TextureUsages::COPY_SRC,
790        view_formats: &[],
791    });
792    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
793    (tex, view)
794}
795
796fn export_vello_png(
797    hal: &Hal,
798    renderer: &mut vello::Renderer,
799    target: &wgpu::Texture,
800    view: &wgpu::TextureView,
801    n: u32,
802    path: &str,
803) -> Result<(), String> {
804    let mut scene = vello::Scene::new();
805    let mut state: u32 = 0x1234_5678;
806    for _ in 0..n {
807        let (x, y, rgba) = lcg_point(&mut state);
808        let r = (rgba & 0xFF) as u8;
809        let g = ((rgba >> 8) & 0xFF) as u8;
810        let b = ((rgba >> 16) & 0xFF) as u8;
811        let a = ((rgba >> 24) & 0xFF) as u8;
812        scene.fill(
813            Fill::NonZero,
814            Affine::IDENTITY,
815            Color::from_rgba8(r, g, b, a),
816            None,
817            &Rect::new(x as f64, y as f64, x as f64 + POINT_PX as f64, y as f64 + POINT_PX as f64),
818        );
819    }
820    renderer
821        .render_to_texture(
822            &hal.device,
823            &hal.queue,
824            &scene,
825            view,
826            &vello::RenderParams {
827                base_color: palette::css::BLACK,
828                width: W,
829                height: H,
830                antialiasing_method: vello::AaConfig::Area,
831            },
832        )
833        .map_err(|e| format!("{e:?}"))?;
834    write_texture_png(hal, target, path)
835}
836
837fn export_directo_png(
838    hal: &Hal,
839    pipelines: &GpuPipelines,
840    target: &wgpu::Texture,
841    view: &wgpu::TextureView,
842    n: u32,
843    path: &str,
844) -> Result<(), String> {
845    let mut batch = GpuBatch::new(pipelines);
846    let mut state: u32 = 0x1234_5678;
847    for _ in 0..n {
848        let (x, y, rgba) = lcg_point(&mut state);
849        let r = (rgba & 0xFF) as u8;
850        let g = ((rgba >> 8) & 0xFF) as u8;
851        let b = ((rgba >> 16) & 0xFF) as u8;
852        let a = ((rgba >> 24) & 0xFF) as u8;
853        batch.add_rect(x, y, POINT_PX, POINT_PX, Color::from_rgba8(r, g, b, a));
854    }
855    let mut encoder = hal.device.create_command_encoder(
856        &wgpu::CommandEncoderDescriptor {
857            label: Some("png-directo-enc"),
858        },
859    );
860    batch.flush(
861        &hal.device,
862        &hal.queue,
863        &mut encoder,
864        view,
865        (W as f32, H as f32),
866        wgpu::LoadOp::Clear(wgpu::Color::BLACK),
867    );
868    hal.queue.submit(std::iter::once(encoder.finish()));
869    hal.device.poll(wgpu::PollType::wait_indefinitely());
870    write_texture_png(hal, target, path)
871}
872
873/// Copia la textura a un buffer mapeable + lee + escribe PNG.
874fn write_texture_png(hal: &Hal, target: &wgpu::Texture, path: &str) -> Result<(), String> {
875    // wgpu pide stride alineado a 256 B en COPY_TEXTURE_TO_BUFFER.
876    let unpadded = (W * 4) as usize;
877    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
878    let padded = ((unpadded + align - 1) / align) * align;
879    let buf_size = (padded * H as usize) as u64;
880
881    let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
882        label: Some("png-readback"),
883        size: buf_size,
884        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
885        mapped_at_creation: false,
886    });
887    let mut encoder = hal.device.create_command_encoder(
888        &wgpu::CommandEncoderDescriptor {
889            label: Some("png-copy-enc"),
890        },
891    );
892    encoder.copy_texture_to_buffer(
893        wgpu::TexelCopyTextureInfo {
894            texture: target,
895            mip_level: 0,
896            origin: wgpu::Origin3d::ZERO,
897            aspect: wgpu::TextureAspect::All,
898        },
899        wgpu::TexelCopyBufferInfo {
900            buffer: &buf,
901            layout: wgpu::TexelCopyBufferLayout {
902                offset: 0,
903                bytes_per_row: Some(padded as u32),
904                rows_per_image: Some(H),
905            },
906        },
907        wgpu::Extent3d {
908            width: W,
909            height: H,
910            depth_or_array_layers: 1,
911        },
912    );
913    hal.queue.submit(std::iter::once(encoder.finish()));
914
915    let slice = buf.slice(..);
916    let (tx, rx) = std::sync::mpsc::channel();
917    slice.map_async(wgpu::MapMode::Read, move |r| {
918        let _ = tx.send(r);
919    });
920    hal.device.poll(wgpu::PollType::wait_indefinitely());
921    rx.recv().map_err(|e| e.to_string())?.map_err(|e| e.to_string())?;
922    let data = slice.get_mapped_range();
923
924    // Desempaquetar las filas (skip padding) y escribir PNG.
925    let mut pixels = Vec::with_capacity((W * H * 4) as usize);
926    for row in 0..H {
927        let start = row as usize * padded;
928        let end = start + unpadded;
929        pixels.extend_from_slice(&data[start..end]);
930    }
931    drop(data);
932    buf.unmap();
933
934    let file = File::create(path).map_err(|e| e.to_string())?;
935    let writer = BufWriter::new(file);
936    let mut encoder = png::Encoder::new(writer, W, H);
937    encoder.set_color(png::ColorType::Rgba);
938    encoder.set_depth(png::BitDepth::Eight);
939    let mut w = encoder.write_header().map_err(|e| e.to_string())?;
940    w.write_image_data(&pixels).map_err(|e| e.to_string())?;
941    Ok(())
942}