tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8
llimphi-gpu-bench/src/main.rs
Historia
Quién tocó qué
crudo
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}