tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 5ae7c737031521b9a873f3d7cff36261fbf7ae06
llimphi-gpu-bench/src/main.rs
History
Who touched what
raw
feat: llimphi standalone — framework UI soberano extraído del monorepo
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}