//! What one frame of `UiRenderNode::draw` costs on the CPU, against the number //! of layers it walks. Recording only: the pass is built and dropped without //! being submitted, so this is the loop's cost and not the GPU's. //! //! cargo test --release --test draw_cost -- --ignored --nocapture //! //! **Read the instruction count, not the clock.** Wall time here swings by 2x //! between runs of one binary on this machine -- more under `cargo test` than //! run directly -- while instructions retired are stable to 0.1%: //! //! perf stat -e instructions:u target/release/.../draw_cost-* --ignored //! //! Measured that way on 2026-09-13, drawing each primitive through its own //! `PrimitiveRender` rather than a match in the renderer costs **6 //! instructions per list drawn**, which is 0.1% of a frame at both 256 and //! 1024 layers. Recording one list into the pass costs wgpu ~5,400. //! //! The instance is leaked on purpose. Dropping the last one makes the Vulkan //! loader unload Mesa's ICD, which faults when a thread that touched Vulkan //! exits -- and libtest runs every test on a spawned thread. use std::time::Instant; use iris::prelude::*; use iris_core::{ GlyphPrimitive, MaskIdx, PrimitiveInst, RectPrimitive, TextureHandle, TexturePrimitive, UiData, UiRegion, UiRenderNode, UiRenderState, }; use wgpu::{Color as GpuColor, *}; const SIZE: u32 = 1024; const FRAMES: u32 = 200; /// Reported as the best of this many batches. The mean moves by 15% between /// runs on this machine, which is more than the thing being measured. const BATCHES: u32 = 8; fn gpu() -> Option<(Device, Queue)> { // Probed rather than assumed: this machine's Vulkan device comes and goes, // and GL is what is left when it is gone. let all = Instance::new(&InstanceDescriptor::default()); let instance = match pollster::block_on(all.request_adapter(&RequestAdapterOptions::default())) { Ok(_) => all, Err(_) => Instance::new(&InstanceDescriptor { backends: Backends::GL, ..Default::default() }), }; // Leaked rather than dropped: see the note at the top of the file. let instance: &'static Instance = Box::leak(Box::new(instance)); let adapter = pollster::block_on(instance.request_adapter(&RequestAdapterOptions::default())).ok()?; println!("adapter: {:?}", adapter.get_info()); pollster::block_on(adapter.request_device(&DeviceDescriptor::default())).ok() } fn config(format: TextureFormat) -> SurfaceConfiguration { SurfaceConfiguration { usage: TextureUsages::RENDER_ATTACHMENT, format, width: SIZE, height: SIZE, present_mode: PresentMode::Fifo, desired_maximum_frame_latency: 2, alpha_mode: CompositeAlphaMode::Auto, view_formats: vec![], } } /// Every layer draws all three primitives, so the renderer takes a different /// path for each list it walks -- which is the case a single-primitive layer /// would never exercise. Images are bound per instance, so there are few. fn fill( ui: &mut UiData, render: &mut UiRenderState, layers: usize, per_layer: usize, ) -> Vec { let rect = ui.primitives.kind::(); let glyph = ui.primitives.kind::(); let texture = ui.primitives.kind::(); let id = ui.widgets.add_strong(Rect::new(UiColor::WHITE)).id(); let handles: Vec<_> = (0..4) .map(|_| ui.textures.add(image::RgbaImage::new(4, 4))) .collect(); let mut layer = 0; for _ in 0..layers { for _ in 0..per_layer { render.layers.write( layer, PrimitiveInst { kind: rect, id, primitive: RectPrimitive::color(UiColor::WHITE), region: UiRegion::FULL, mask_idx: MaskIdx::NONE, }, ); render.layers.write( layer, PrimitiveInst { kind: glyph, id, primitive: GlyphPrimitive { uv_min: vec2(0.0, 0.0), uv_max: vec2(1.0, 1.0), layer: 0, color: UiColor::WHITE, flags: 0, }, region: UiRegion::FULL, mask_idx: MaskIdx::NONE, }, ); } for h in &handles[..2] { render.layers.write( layer, PrimitiveInst { kind: texture, id, primitive: TexturePrimitive::from(h), region: UiRegion::FULL, mask_idx: MaskIdx::NONE, }, ); } layer = render.layers.next(layer); } handles } fn frame_cost(device: &Device, queue: &Queue, layers: usize, per_layer: usize) -> f64 { let format = TextureFormat::Bgra8Unorm; let mut node = UiRenderNode::new(device, &config(format)); let mut ui = UiData::default(); let mut render = UiRenderState::new(); let _handles = fill(&mut ui, &mut render, layers, per_layer); node.update(device, queue, &mut ui, &mut render); let target = device.create_texture(&TextureDescriptor { label: Some("draw cost"), size: Extent3d { width: SIZE, height: SIZE, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: TextureDimension::D2, format, usage: TextureUsages::RENDER_ATTACHMENT, view_formats: &[], }); let view = target.create_view(&TextureViewDescriptor::default()); let record = |frames: u32| { let start = Instant::now(); for _ in 0..frames { let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor::default()); { let pass = &mut encoder.begin_render_pass(&RenderPassDescriptor { color_attachments: &[Some(RenderPassColorAttachment { view: &view, resolve_target: None, ops: Operations { load: LoadOp::Clear(GpuColor::BLACK), store: StoreOp::Store, }, depth_slice: None, })], ..Default::default() }); node.draw(pass); } drop(encoder.finish()); } start.elapsed().as_secs_f64() / frames as f64 }; record(FRAMES / 4); (0..BATCHES) .map(|_| record(FRAMES)) .fold(f64::MAX, f64::min) } #[test] #[ignore = "measurement, not a check"] fn draw_cost_by_layer_count() { let Some((device, queue)) = gpu() else { panic!("no wgpu device; see the this-machine-graphics notes"); }; println!( "layers, each 8 rects + 8 glyphs + 2 images: us/frame (us per layer), best of {BATCHES}" ); let base = frame_cost(&device, &queue, 1, 8) * 1e6; for layers in [8, 64, 256, 1024] { let per_frame = frame_cost(&device, &queue, layers, 8) * 1e6; // Net of the empty pass, which is the same in any version of this. println!( "{layers:>5}: {per_frame:8.1} us ({:.3} us)", (per_frame - base).max(0.0) / layers as f64 ); } }