tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 333dca1d5fb473825c9128d402d4cc4e2a5176fc
llimphi-raster/examples/spike_gpu_directo.rs
Historia
Quién tocó qué
crudo
refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
1//! Spike Fase 0 — GPU directo vs vello.
2//!
3//! Compara el tiempo total CPU+GPU por frame para pintar N puntos en una
4//! textura `Rgba8Unorm` 1024×1024 con dos estrategias:
5//!
6//! - **Vello**: una llamada `Scene::fill(Rect 1×1)` por punto, luego
7//! `vello::Renderer::render_to_texture`.
8//! - **GPU directo**: un pipeline `wgpu` con instanced quad. Cada punto es
9//! una instancia `[x:f32, y:f32, rgba:u32]`. Una sola draw call.
10//!
11//! Tamaños: 100K, 500K, 1M puntos. 10 frames de warmup + 20 medidos por
12//! tamaño. Reporta mediana y factor de aceleración.
13//!
14//! Criterio de aceptación del SDD (`llimphi/SDD.md` §"GPU directo wgpu"):
15//! factor ≥ 5× a 500K → seguir con Fase 1. Si no, abortar.
16//!
17//! Corre con: `cargo run -p llimphi-raster --example spike_gpu_directo --release`.
18
19use std::io::Write;
20use std::time::Instant;
21
22use llimphi_hal::{wgpu, Hal};
23use llimphi_raster::{
24 kurbo::{Affine, Rect},
25 peniko::{color::palette, Color, Fill},
26 vello,
27};
28
29const W: u32 = 1024;
30const H: u32 = 1024;
31const TARGET_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
32const WARMUP_FRAMES: usize = 5;
33const MEASURED_FRAMES: usize = 15;
34// Vello revienta (SIGSEGV en `vello_encoding::path::flatten`) cuando la
35// escena pasa de ~200K paths con los `Limits::default()` que pide el HAL.
36// Es exactamente el techo del SDD §"GPU directo wgpu". Lo medimos hasta
37// donde vello aguanta; el lado directo se mide a sizes mucho mayores para
38// confirmar el régimen post-techo.
39const VELLO_SIZES: &[usize] = &[25_000, 50_000, 100_000, 200_000];
40const DIRECTO_SIZES: &[usize] = &[100_000, 500_000, 1_000_000, 5_000_000];
41
42fn main() {
43 let hal = pollster::block_on(Hal::new(None)).expect("hal");
44
45 // Textura destino compartida por ambos backends. STORAGE_BINDING para
46 // vello (compute), RENDER_ATTACHMENT para el pipeline directo. Idéntica
47 // al `intermediate` de `WinitSurface` (HAL real).
48 let (target, target_view) = create_target(&hal.device);
49
50 let mut vello_renderer = vello::Renderer::new(
51 &hal.device,
52 vello::RendererOptions {
53 use_cpu: false,
54 antialiasing_support: vello::AaSupport {
55 area: true,
56 msaa8: false,
57 msaa16: false,
58 },
59 num_init_threads: None,
60 pipeline_cache: None,
61 },
62 )
63 .expect("vello renderer");
64
65 let directo = DirectoPipeline::new(&hal.device);
66
67 println!();
68 println!("spike GPU directo — target {W}×{H} Rgba8Unorm, headless");
69 println!("warmup {WARMUP_FRAMES}, measured {MEASURED_FRAMES}");
70 println!();
71 println!("vello (scene.fill por punto):");
72 println!(" {:>10} | {:>14}", "N", "ms / frame");
73 println!(" {:->10} + {:->14}", "", "");
74 let mut vello_100k_ms: Option<f64> = None;
75 for &n in VELLO_SIZES {
76 let points = gen_points(n);
77 let ms = bench_vello(&hal, &mut vello_renderer, &target_view, &points);
78 println!(" {:>10} | {:>14.3}", n, ms);
79 let _ = std::io::stdout().flush();
80 if n == 100_000 {
81 vello_100k_ms = Some(ms);
82 }
83 }
84 println!();
85 println!("GPU directo (instanced quad, 1 draw call):");
86 println!(" {:>10} | {:>14}", "N", "ms / frame");
87 println!(" {:->10} + {:->14}", "", "");
88 let mut directo_100k_ms: Option<f64> = None;
89 for &n in DIRECTO_SIZES {
90 let points = gen_points(n);
91 let ms = bench_directo(&hal, &directo, &target_view, &points);
92 println!(" {:>10} | {:>14.3}", n, ms);
93 let _ = std::io::stdout().flush();
94 if n == 100_000 {
95 directo_100k_ms = Some(ms);
96 }
97 }
98 println!();
99 if let (Some(v), Some(d)) = (vello_100k_ms, directo_100k_ms) {
100 let factor = v / d;
101 let verdict = if factor >= 5.0 { "PASA" } else { "ABORTAR" };
102 println!(
103 "veredicto Fase 0 @ 100K: vello {:.2} ms / directo {:.2} ms = {:.2}× → {}",
104 v, d, factor, verdict
105 );
106 println!("(SDD pide ≥5× a 500K, pero vello no llega a 500K — techo medido <300K)");
107 }
108 println!();
109 // Mantener vivo el texture para evitar warnings.
110 drop(target);
111}
112
113fn create_target(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView) {
114 let tex = device.create_texture(&wgpu::TextureDescriptor {
115 label: Some("spike-target"),
116 size: wgpu::Extent3d {
117 width: W,
118 height: H,
119 depth_or_array_layers: 1,
120 },
121 mip_level_count: 1,
122 sample_count: 1,
123 dimension: wgpu::TextureDimension::D2,
124 format: TARGET_FORMAT,
125 usage: wgpu::TextureUsages::STORAGE_BINDING
126 | wgpu::TextureUsages::RENDER_ATTACHMENT
127 | wgpu::TextureUsages::TEXTURE_BINDING,
128 view_formats: &[],
129 });
130 let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
131 (tex, view)
132}
133
134/// LCG numerical recipes — determinista, sin dependencias.
135fn gen_points(n: usize) -> Vec<(f32, f32, u32)> {
136 let mut state: u32 = 0x1234_5678;
137 let mut out = Vec::with_capacity(n);
138 for _ in 0..n {
139 state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
140 let x = (state % W) as f32;
141 state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
142 let y = (state % H) as f32;
143 state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
144 // RGBA packed little-endian: R en byte bajo (queda igual a como lo
145 // lee el shader: `rgba & 0xFF` → R).
146 let rgba = (state & 0x00FF_FFFF) | 0xFF00_0000;
147 out.push((x, y, rgba));
148 }
149 out
150}
151
152fn bench_vello(
153 hal: &Hal,
154 renderer: &mut vello::Renderer,
155 target: &wgpu::TextureView,
156 points: &[(f32, f32, u32)],
157) -> f64 {
158 let mut scene = vello::Scene::new();
159 let mut samples: Vec<f64> = Vec::with_capacity(MEASURED_FRAMES);
160 for frame in 0..(WARMUP_FRAMES + MEASURED_FRAMES) {
161 let t0 = Instant::now();
162 scene.reset();
163 for &(x, y, rgba) in points {
164 let r = (rgba & 0xFF) as u8;
165 let g = ((rgba >> 8) & 0xFF) as u8;
166 let b = ((rgba >> 16) & 0xFF) as u8;
167 let a = ((rgba >> 24) & 0xFF) as u8;
168 let xf = x as f64;
169 let yf = y as f64;
170 scene.fill(
171 Fill::NonZero,
172 Affine::IDENTITY,
173 Color::from_rgba8(r, g, b, a),
174 None,
175 &Rect::new(xf, yf, xf + 1.0, yf + 1.0),
176 );
177 }
178 renderer
179 .render_to_texture(
180 &hal.device,
181 &hal.queue,
182 &scene,
183 target,
184 &vello::RenderParams {
185 base_color: palette::css::BLACK,
186 width: W,
187 height: H,
188 antialiasing_method: vello::AaConfig::Area,
189 },
190 )
191 .expect("vello render");
192 // Bloquear hasta que la GPU termine este frame. Sin esto medimos
193 // sólo el submit + queue building, no el trabajo real.
194 hal.device.poll(wgpu::PollType::wait_indefinitely());
195 let dt = t0.elapsed().as_secs_f64() * 1000.0;
196 if frame >= WARMUP_FRAMES {
197 samples.push(dt);
198 }
199 }
200 median(&mut samples)
201}
202
203fn bench_directo(
204 hal: &Hal,
205 pipe: &DirectoPipeline,
206 target: &wgpu::TextureView,
207 points: &[(f32, f32, u32)],
208) -> f64 {
209 // Buffer de instancias dimensionado para el peor caso.
210 let bytes_per_inst = std::mem::size_of::<[u32; 3]>(); // [x:f32, y:f32, rgba:u32] = 12B
211 let inst_buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
212 label: Some("spike-directo-inst"),
213 size: (points.len() * bytes_per_inst) as u64,
214 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
215 mapped_at_creation: false,
216 });
217
218 let mut samples: Vec<f64> = Vec::with_capacity(MEASURED_FRAMES);
219 for frame in 0..(WARMUP_FRAMES + MEASURED_FRAMES) {
220 let t0 = Instant::now();
221 // Empaquetar instancias: igual a la "scene build" del lado vello,
222 // para que la comparación sea fair (ambos parten de los mismos
223 // puntos crudos).
224 let bytes = pack_instances(points);
225 hal.queue.write_buffer(&inst_buf, 0, &bytes);
226
227 let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
228 label: Some("spike-directo-enc"),
229 });
230 {
231 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
232 label: Some("spike-directo-pass"),
233 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
234 view: target,
235 resolve_target: None,
236 depth_slice: None,
237 ops: wgpu::Operations {
238 load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
239 store: wgpu::StoreOp::Store,
240 },
241 })],
242 depth_stencil_attachment: None,
243 timestamp_writes: None,
244 occlusion_query_set: None,
245 });
246 pass.set_pipeline(&pipe.pipeline);
247 pass.set_vertex_buffer(0, inst_buf.slice(..));
248 // 6 vértices por instancia (2 tris = quad), N instancias.
249 pass.draw(0..6, 0..points.len() as u32);
250 }
251 hal.queue.submit(std::iter::once(encoder.finish()));
252 hal.device.poll(wgpu::PollType::wait_indefinitely());
253 let dt = t0.elapsed().as_secs_f64() * 1000.0;
254 if frame >= WARMUP_FRAMES {
255 samples.push(dt);
256 }
257 }
258 median(&mut samples)
259}
260
261fn pack_instances(points: &[(f32, f32, u32)]) -> Vec<u8> {
262 let mut v = Vec::with_capacity(points.len() * 12);
263 for &(x, y, rgba) in points {
264 v.extend_from_slice(&x.to_ne_bytes());
265 v.extend_from_slice(&y.to_ne_bytes());
266 v.extend_from_slice(&rgba.to_ne_bytes());
267 }
268 v
269}
270
271fn median(samples: &mut [f64]) -> f64 {
272 samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
273 samples[samples.len() / 2]
274}
275
276/// Pipeline trivial para el bench: instanced quad sin texturas, color
277/// per-instance. No es código de producción — es el "mock GPU directo"
278/// que pide la Fase 0 del SDD para medir el techo alcanzable.
279struct DirectoPipeline {
280 pipeline: wgpu::RenderPipeline,
281}
282
283impl DirectoPipeline {
284 fn new(device: &wgpu::Device) -> Self {
285 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
286 label: Some("spike-directo-shader"),
287 source: wgpu::ShaderSource::Wgsl(WGSL.into()),
288 });
289 let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
290 label: Some("spike-directo-layout"),
291 bind_group_layouts: &[],
292 push_constant_ranges: &[],
293 });
294 let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
295 label: Some("spike-directo-pipeline"),
296 layout: Some(&layout),
297 vertex: wgpu::VertexState {
298 module: &shader,
299 entry_point: Some("vs"),
300 compilation_options: Default::default(),
301 buffers: &[wgpu::VertexBufferLayout {
302 array_stride: 12,
303 step_mode: wgpu::VertexStepMode::Instance,
304 attributes: &[
305 wgpu::VertexAttribute {
306 format: wgpu::VertexFormat::Float32x2,
307 offset: 0,
308 shader_location: 0,
309 },
310 wgpu::VertexAttribute {
311 format: wgpu::VertexFormat::Uint32,
312 offset: 8,
313 shader_location: 1,
314 },
315 ],
316 }],
317 },
318 primitive: wgpu::PrimitiveState {
319 topology: wgpu::PrimitiveTopology::TriangleList,
320 strip_index_format: None,
321 front_face: wgpu::FrontFace::Ccw,
322 cull_mode: None,
323 unclipped_depth: false,
324 polygon_mode: wgpu::PolygonMode::Fill,
325 conservative: false,
326 },
327 depth_stencil: None,
328 multisample: wgpu::MultisampleState::default(),
329 fragment: Some(wgpu::FragmentState {
330 module: &shader,
331 entry_point: Some("fs"),
332 compilation_options: Default::default(),
333 targets: &[Some(wgpu::ColorTargetState {
334 format: TARGET_FORMAT,
335 blend: None,
336 write_mask: wgpu::ColorWrites::ALL,
337 })],
338 }),
339 multiview: None,
340 cache: None,
341 });
342 Self { pipeline }
343 }
344}
345
346const WGSL: &str = r#"
347struct Inst {
348 @location(0) xy: vec2<f32>,
349 @location(1) rgba: u32,
350};
351
352struct V2F {
353 @builtin(position) pos: vec4<f32>,
354 @location(0) color: vec4<f32>,
355};
356
357const W: f32 = 1024.0;
358const H: f32 = 1024.0;
359
360@vertex
361fn vs(@builtin(vertex_index) vid: u32, inst: Inst) -> V2F {
362 // Quad 1.5px alrededor de (inst.xy + 0.5). Pixel-centered.
363 var corners = array<vec2<f32>, 6>(
364 vec2<f32>(-0.75, -0.75),
365 vec2<f32>( 0.75, -0.75),
366 vec2<f32>( 0.75, 0.75),
367 vec2<f32>(-0.75, -0.75),
368 vec2<f32>( 0.75, 0.75),
369 vec2<f32>(-0.75, 0.75),
370 );
371 let off = corners[vid];
372 let px = inst.xy + vec2<f32>(0.5, 0.5) + off;
373 // pixel → NDC, Y invertido (vello / textura framebuffer).
374 let ndc = vec2<f32>(px.x / W * 2.0 - 1.0, 1.0 - px.y / H * 2.0);
375
376 let r = f32( inst.rgba & 0xFFu) / 255.0;
377 let g = f32((inst.rgba >> 8u) & 0xFFu) / 255.0;
378 let b = f32((inst.rgba >> 16u) & 0xFFu) / 255.0;
379 let a = f32((inst.rgba >> 24u) & 0xFFu) / 255.0;
380
381 var out: V2F;
382 out.pos = vec4<f32>(ndc, 0.0, 1.0);
383 out.color = vec4<f32>(r, g, b, a);
384 return out;
385}
386
387@fragment
388fn fs(in: V2F) -> @location(0) vec4<f32> {
389 return in.color;
390}
391"#;