//! 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 //! //! Wall time is the wrong number to read for anything under a few percent -- //! it varied by 2x between runs of one unchanged binary where instructions //! retired varied by 0.1%. Count those instead: //! //! perf stat -e instructions:u target/release/.../draw_cost-* --ignored //! //! That is how `PrimitiveRender` was measured against a match in the renderer: //! 6 instructions per list drawn, against the ~5,400 wgpu spends recording //! one. //! //! The instance is leaked deliberately. A Vulkan loader may unload the driver //! when the last one drops, which can fault as a thread that used it exits -- //! and every test runs on a spawned thread. use std::time::Instant; use iris::prelude::*; use iris_core::{ GlyphPrimitive, MaskIdx, MoveIdx, 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, since the mean moves by more /// than the thing being measured. const BATCHES: u32 = 8; fn gpu() -> Option<(Device, Queue)> { // Probed rather than assumed: there may be no Vulkan adapter, and GL is // what is left when there is not. let all = Instance::new(InstanceDescriptor::new_without_display_handle()); let instance = match pollster::block_on(all.request_adapter(&RequestAdapterOptions::default())) { Ok(_) => all, Err(_) => Instance::new(InstanceDescriptor { backends: Backends::GL, ..InstanceDescriptor::new_without_display_handle() }), }; // 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, color_space: SurfaceColorSpace::Auto, 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, move_idx: MoveIdx::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, move_idx: MoveIdx::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, move_idx: MoveIdx::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 ); } }