tawasuyu

tawasuyu/llimphi

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

Rust 238TOML 102Markdown 83Shell 24178 Definitions

⎇ 5ae7c737031521b9a873f3d7cff36261fbf7ae06 llimphi-gpu-bench/src/main.rs Rust · 941 lines · 32.1 KB History Who touched what raw

feat: llimphi standalone — framework UI soberano extraído del monorepo e65e9cc62 · 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 acá.");
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::Maintain::Wait);
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::Maintain::Wait);
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::Maintain::Wait);
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::Maintain::Wait);
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                    ops: wgpu::Operations {
589                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
590                        store: wgpu::StoreOp::Store,
591                    },
592                })],
593                depth_stencil_attachment: None,
594                timestamp_writes: None,
595                occlusion_query_set: None,
596            });
597            pass.set_pipeline(&pipelines.rects);
598            pass.set_bind_group(0, &bind_group, &[]);
599            pass.set_vertex_buffer(0, inst_buf.slice(..));
600            pass.draw(0..6, 0..n);
601        }
602        hal.queue.submit(std::iter::once(encoder.finish()));
603        hal.device.poll(wgpu::Maintain::Wait);
604        let dt = t0.elapsed().as_secs_f64() * 1000.0;
605        if frame >= WARMUP {
606            samples.push(dt);
607        }
608    }
609    median(&mut samples)
610}
611
612fn lcg_point(state: &mut u32) -> (f32, f32, u32) {
613    *state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
614    let x = (*state % W) as f32;
615    *state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
616    let y = (*state % H) as f32;
617    *state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
618    // Colores: piso 128 por canal para que las PNGs de verificación
619    // se vean (sin esto el LCG produce muchos negros casi puros, y
620    // los puntos quedan invisibles en pantalla aunque estén pintados).
621    let r = 128 | ((*state >> 0) & 0x7F) as u8;
622    let g = 128 | ((*state >> 8) & 0x7F) as u8;
623    let b = 128 | ((*state >> 16) & 0x7F) as u8;
624    let rgba = (r as u32) | ((g as u32) << 8) | ((b as u32) << 16) | 0xFF00_0000;
625    (x, y, rgba)
626}
627
628const POINT_PX: f32 = 2.5;
629
630fn median(samples: &mut [f64]) -> f64 {
631    samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
632    samples[samples.len() / 2]
633}
634
635// ============================================================
636// Veredictos
637// ============================================================
638
639fn print_spike_verdict(rows: &[SpikeRow]) {
640    let at_500k = rows.iter().find(|r| r.n == 500_000);
641    match at_500k {
642        Some(r) if r.vello_crashed => {
643            println!("**Veredicto Fase 0:** Vello revienta antes de 500K → directo es el único path posible en ese régimen. PASA cualitativo.");
644        }
645        Some(r) => match r.factor {
646            Some(f) if f >= 5.0 => {
647                println!("**Veredicto Fase 0:** factor a 500K = {:.2}× ≥ 5 → **PASA** (criterio SDD cumplido).", f);
648            }
649            Some(f) => {
650                println!("**Veredicto Fase 0:** factor a 500K = {:.2}× < 5 → **ABORTAR** según criterio literal del SDD.", f);
651                println!("Pero ver si vello revienta a tamaños mayores — eso cambia el veredicto cualitativamente.");
652            }
653            None => {
654                println!("**Veredicto Fase 0:** sin datos para 500K (vello crashed o N no medido). Revisar tabla arriba.");
655            }
656        },
657        None => {
658            println!("**Veredicto Fase 0:** no se midió 500K en este run. Revisar tabla arriba.");
659        }
660    }
661    println!();
662}
663
664fn print_persistent_verdict(
665    directo: &[ScaleRow],
666    vello: &[(u32, f64)],
667) {
668    let d_1m = directo.iter().find(|r| r.n == 1_000_000);
669    let v_1m = vello.iter().find(|(n, _)| *n == 1_000_000);
670    match d_1m {
671        Some(r) if r.fps >= 60.0 => {
672            println!(
673                "**Veredicto persistente @ 1M:** directo {:.1} fps ≥ 60 → **PASA**.",
674                r.fps
675            );
676        }
677        Some(r) => {
678            println!(
679                "**Veredicto persistente @ 1M:** directo {:.1} fps < 60 → falla incluso sin rebuild.",
680                r.fps
681            );
682        }
683        None => println!("**Veredicto:** sin datos a 1M."),
684    }
685    if let (Some(d), Some((_, v_ms))) = (d_1m, v_1m) {
686        let factor = v_ms / d.ms;
687        println!(
688            "**Factor persistente @ 1M:** vello {:.1} ms / directo {:.1} ms = {:.2}× ({})",
689            v_ms,
690            d.ms,
691            factor,
692            if factor >= 5.0 { "≥5×" } else { "<5×" }
693        );
694    }
695    println!();
696}
697
698fn print_scale_verdict(rows: &[ScaleRow]) {
699    let at_1m = rows.iter().find(|r| r.n == 1_000_000);
700    match at_1m {
701        Some(r) if r.fps >= 60.0 => {
702            println!("**Veredicto Fase 0 (objetivo 60 fps @ 1M):** {:.1} fps ≥ 60 → **PASA**.", r.fps);
703        }
704        Some(r) => {
705            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);
706        }
707        None => {
708            println!("**Veredicto:** sin datos para 1M.");
709        }
710    }
711    println!();
712}
713
714fn print_summary(
715    spike: &[SpikeRow],
716    scale: &[ScaleRow],
717    persist_directo: &[ScaleRow],
718    persist_vello: &[(u32, f64)],
719) {
720    println!("Copiar lo que sigue al chat:");
721    println!();
722    println!("```");
723    println!("rebuild por frame — vello vs directo:");
724    for r in spike {
725        let v = match (r.vello_crashed, r.vello_ms) {
726            (true, _) => "crash".to_string(),
727            (_, Some(ms)) => format!("{:.1}ms", ms),
728            _ => "-".to_string(),
729        };
730        let f = r
731            .factor
732            .map(|x| format!("{:.2}x", x))
733            .unwrap_or_else(|| "-".to_string());
734        println!("  {:>10}  vello={:>10}  directo={:>7.1}ms  factor={}", fmt_int(r.n), v, r.directo_ms, f);
735    }
736    println!();
737    println!("rebuild por frame — escalado directo:");
738    for r in scale {
739        println!("  {:>10}  {:>7.1}ms  {:>5.1}fps  {:>5.2}Mprim/s", fmt_int(r.n), r.ms, r.fps, r.mps);
740    }
741    println!();
742    println!("persistente (datos fijos, sólo redraw):");
743    for r in persist_directo {
744        let v_ms = persist_vello
745            .iter()
746            .find(|(n, _)| *n == r.n)
747            .map(|(_, ms)| format!("{:>7.1}ms", ms))
748            .unwrap_or_else(|| "       —".to_string());
749        let factor = persist_vello
750            .iter()
751            .find(|(n, _)| *n == r.n)
752            .map(|(_, vms)| format!("factor={:.2}x", vms / r.ms))
753            .unwrap_or_else(|| "factor=  —  ".to_string());
754        println!(
755            "  {:>10}  vello={}  directo={:>7.1}ms  {}  {:>5.1}fps  {:>5.2}Mprim/s",
756            fmt_int(r.n),
757            v_ms,
758            r.ms,
759            factor,
760            r.fps,
761            r.mps,
762        );
763    }
764    println!("```");
765}
766
767// ============================================================
768// Textura destino + PNG export
769// ============================================================
770
771fn make_target(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView) {
772    let tex = device.create_texture(&wgpu::TextureDescriptor {
773        label: Some("bench-target"),
774        size: wgpu::Extent3d {
775            width: W,
776            height: H,
777            depth_or_array_layers: 1,
778        },
779        mip_level_count: 1,
780        sample_count: 1,
781        dimension: wgpu::TextureDimension::D2,
782        format: FMT,
783        // RENDER_ATTACHMENT para el directo, STORAGE_BINDING para vello,
784        // TEXTURE_BINDING + COPY_SRC para poder leer (PNG export).
785        usage: wgpu::TextureUsages::RENDER_ATTACHMENT
786            | wgpu::TextureUsages::STORAGE_BINDING
787            | wgpu::TextureUsages::TEXTURE_BINDING
788            | wgpu::TextureUsages::COPY_SRC,
789        view_formats: &[],
790    });
791    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
792    (tex, view)
793}
794
795fn export_vello_png(
796    hal: &Hal,
797    renderer: &mut vello::Renderer,
798    target: &wgpu::Texture,
799    view: &wgpu::TextureView,
800    n: u32,
801    path: &str,
802) -> Result<(), String> {
803    let mut scene = vello::Scene::new();
804    let mut state: u32 = 0x1234_5678;
805    for _ in 0..n {
806        let (x, y, rgba) = lcg_point(&mut state);
807        let r = (rgba & 0xFF) as u8;
808        let g = ((rgba >> 8) & 0xFF) as u8;
809        let b = ((rgba >> 16) & 0xFF) as u8;
810        let a = ((rgba >> 24) & 0xFF) as u8;
811        scene.fill(
812            Fill::NonZero,
813            Affine::IDENTITY,
814            Color::from_rgba8(r, g, b, a),
815            None,
816            &Rect::new(x as f64, y as f64, x as f64 + POINT_PX as f64, y as f64 + POINT_PX as f64),
817        );
818    }
819    renderer
820        .render_to_texture(
821            &hal.device,
822            &hal.queue,
823            &scene,
824            view,
825            &vello::RenderParams {
826                base_color: palette::css::BLACK,
827                width: W,
828                height: H,
829                antialiasing_method: vello::AaConfig::Area,
830            },
831        )
832        .map_err(|e| format!("{e:?}"))?;
833    write_texture_png(hal, target, path)
834}
835
836fn export_directo_png(
837    hal: &Hal,
838    pipelines: &GpuPipelines,
839    target: &wgpu::Texture,
840    view: &wgpu::TextureView,
841    n: u32,
842    path: &str,
843) -> Result<(), String> {
844    let mut batch = GpuBatch::new(pipelines);
845    let mut state: u32 = 0x1234_5678;
846    for _ in 0..n {
847        let (x, y, rgba) = lcg_point(&mut state);
848        let r = (rgba & 0xFF) as u8;
849        let g = ((rgba >> 8) & 0xFF) as u8;
850        let b = ((rgba >> 16) & 0xFF) as u8;
851        let a = ((rgba >> 24) & 0xFF) as u8;
852        batch.add_rect(x, y, POINT_PX, POINT_PX, Color::from_rgba8(r, g, b, a));
853    }
854    let mut encoder = hal.device.create_command_encoder(
855        &wgpu::CommandEncoderDescriptor {
856            label: Some("png-directo-enc"),
857        },
858    );
859    batch.flush(
860        &hal.device,
861        &hal.queue,
862        &mut encoder,
863        view,
864        (W as f32, H as f32),
865        wgpu::LoadOp::Clear(wgpu::Color::BLACK),
866    );
867    hal.queue.submit(std::iter::once(encoder.finish()));
868    hal.device.poll(wgpu::Maintain::Wait);
869    write_texture_png(hal, target, path)
870}
871
872/// Copia la textura a un buffer mapeable + lee + escribe PNG.
873fn write_texture_png(hal: &Hal, target: &wgpu::Texture, path: &str) -> Result<(), String> {
874    // wgpu pide stride alineado a 256 B en COPY_TEXTURE_TO_BUFFER.
875    let unpadded = (W * 4) as usize;
876    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
877    let padded = ((unpadded + align - 1) / align) * align;
878    let buf_size = (padded * H as usize) as u64;
879
880    let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
881        label: Some("png-readback"),
882        size: buf_size,
883        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
884        mapped_at_creation: false,
885    });
886    let mut encoder = hal.device.create_command_encoder(
887        &wgpu::CommandEncoderDescriptor {
888            label: Some("png-copy-enc"),
889        },
890    );
891    encoder.copy_texture_to_buffer(
892        wgpu::TexelCopyTextureInfo {
893            texture: target,
894            mip_level: 0,
895            origin: wgpu::Origin3d::ZERO,
896            aspect: wgpu::TextureAspect::All,
897        },
898        wgpu::TexelCopyBufferInfo {
899            buffer: &buf,
900            layout: wgpu::TexelCopyBufferLayout {
901                offset: 0,
902                bytes_per_row: Some(padded as u32),
903                rows_per_image: Some(H),
904            },
905        },
906        wgpu::Extent3d {
907            width: W,
908            height: H,
909            depth_or_array_layers: 1,
910        },
911    );
912    hal.queue.submit(std::iter::once(encoder.finish()));
913
914    let slice = buf.slice(..);
915    let (tx, rx) = std::sync::mpsc::channel();
916    slice.map_async(wgpu::MapMode::Read, move |r| {
917        let _ = tx.send(r);
918    });
919    hal.device.poll(wgpu::Maintain::Wait);
920    rx.recv().map_err(|e| e.to_string())?.map_err(|e| e.to_string())?;
921    let data = slice.get_mapped_range();
922
923    // Desempaquetar las filas (skip padding) y escribir PNG.
924    let mut pixels = Vec::with_capacity((W * H * 4) as usize);
925    for row in 0..H {
926        let start = row as usize * padded;
927        let end = start + unpadded;
928        pixels.extend_from_slice(&data[start..end]);
929    }
930    drop(data);
931    buf.unmap();
932
933    let file = File::create(path).map_err(|e| e.to_string())?;
934    let writer = BufWriter::new(file);
935    let mut encoder = png::Encoder::new(writer, W, H);
936    encoder.set_color(png::ColorType::Rgba);
937    encoder.set_depth(png::BitDepth::Eight);
938    let mut w = encoder.write_header().map_err(|e| e.to_string())?;
939    w.write_image_data(&pixels).map_err(|e| e.to_string())?;
940    Ok(())
941}