Two majors, and the renderer is under everything else that is left to extract, so it goes before the slices that would otherwise be written twice. `image` 0.25.6 -> 0.25.10 rides along; `winit` stays on 0.30.12, since 0.31 is only a prerelease. What the API asked for: - An instance now takes the display it will present on, and GLES on Wayland needs it -- so the window the surface is made from is handed over with it. - `get_current_texture` returns a `CurrentSurfaceTexture` rather than a `Result`, which replaces an `unwrap` that would have panicked on a resize or an occluded window: reconfigure when the surface is outdated, lost or suboptimal, and skip the frame when there is nothing to draw into. - Presenting moved to the queue, after `pre_present_notify` as before. - Bind group layouts and vertex buffer layouts are sparse now, so a pipeline states `Some(layout)` per slot. Verified with the three render checks and `tests/draw_cost.rs`: identical pictures, and 33.6/167/587/2855 us per frame at 8/64/256/1024 layers against 33.3/161/588/2903 on wgpu 28. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
207 lines
7.5 KiB
Rust
207 lines
7.5 KiB
Rust
//! 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::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<TextureHandle> {
|
|
let rect = ui.primitives.kind::<RectPrimitive>();
|
|
let glyph = ui.primitives.kind::<GlyphPrimitive>();
|
|
let texture = ui.primitives.kind::<TexturePrimitive>();
|
|
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
|
|
);
|
|
}
|
|
}
|