`UiScalar` is `Rel` beside `Px` rather than two floats, so composing a position down a chain of boxes adds exactly and rounds only at the two multiplies `within` makes. `UiSpan`, `UiRegion` and `UiVec2` follow it, the hand-written `Hash` goes away with the bits it hashed, and `impl_op!` grows a `same` form for a type whose fields are not the same kind of number. `Len` is still floats, so the seam converts: `Px::from_f32` where a span adds a child's length to its cursor, and `to_f32` where something outside layout wants pixels. Those go when `Len` follows. The GPU reads what the CPU wrote: the instance attributes are `Sint32x2` and the shader decodes by `1/64` and `1/2^24`, both exact in `f32`, then composes the move chain in floats as before. It has to agree with itself frame to frame rather than with the CPU to the last bit. Two things fell out of making the numbers exact. `floor` at the rasteriser was picking the pixel below wherever a fraction divided a window exactly. A fifth of 1920 is 383.99998 through a rounded `Rel` -- and was 384.0 through an `f32` that happened to round up -- so five tabs each lost their last column. `snap_floor` takes a coordinate within half a step of a boundary to be on it, which is the same rule as everywhere else here: decide where values do not land. A widget measured on one layer and drawn again on another kept the first layer, because `try_reuse` compared everything about a retained drawing except which list it sits in. `Stack` does exactly that for its background, so every panel's text went under its own background. It only worked before because the two asks differed by a rounding and forced a redraw; `tests/retained.rs` pins it now, and `ReuseOutcome` can say `WrongLayer`. Checked: fmt, clippy, 100 tests, 100 generated seeds in 70 s, all five shrinker cases at 300 seeds. `tabs`, `view` and `minimal` render byte-identical at 1920x1200; `random` differs in 36 pixels by one level; `text` differs where glyph origins moved onto the grid -- same positions, same spacing, different subpixel coverage, checked at 6x against the old render. Measured on the way: with the fuzzer comparing for *equality* rather than within 0.05 px, `resize`, `repaint` and `size-change` already pass 100 seeds. `reorder` fails one seed by exactly one step, which is the `Len` seam above. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
224 lines
7.7 KiB
Rust
224 lines
7.7 KiB
Rust
//! What the vertex shader's move-chain walk costs, against how many nested
|
|
//! region nodes a primitive resolves through.
|
|
//!
|
|
//! cargo test --release --test chain_cost -- --ignored --nocapture
|
|
//!
|
|
//! Timed on the GPU with timestamp queries rather than by the clock: wall time
|
|
//! here varied by 2x between runs of one unchanged binary. The pass is
|
|
//! submitted and waited on, so this is the GPU's cost and not the recording
|
|
//! loop's -- which is what `draw_cost.rs` measures instead.
|
|
//!
|
|
//! The instances are two pixels wide so that vertex work dominates; a chain
|
|
//! walk that does not show up against small quads will not show up against
|
|
//! anything.
|
|
//!
|
|
//! The instance is leaked deliberately, for the reason `draw_cost.rs` gives.
|
|
|
|
use iris::prelude::*;
|
|
use iris_core::{
|
|
MaskIdx, MoveIdx, PrimitiveInst, RectPrimitive, UiData, UiRegion, UiRenderNode, UiRenderState,
|
|
UiScalar, UiSpan,
|
|
};
|
|
use wgpu::{Color as GpuColor, *};
|
|
|
|
const SIZE: u32 = 1024;
|
|
const INSTANCES: usize = 200_000;
|
|
const FRAMES: u32 = 20;
|
|
/// 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, f32)> {
|
|
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()
|
|
}),
|
|
};
|
|
let instance: &'static Instance = Box::leak(Box::new(instance));
|
|
let adapter =
|
|
pollster::block_on(instance.request_adapter(&RequestAdapterOptions::default())).ok()?;
|
|
if !adapter.features().contains(Features::TIMESTAMP_QUERY) {
|
|
println!("no timestamp queries on {:?}", adapter.get_info().name);
|
|
return None;
|
|
}
|
|
println!("adapter: {:?}", adapter.get_info().name);
|
|
let (device, queue) = pollster::block_on(adapter.request_device(&DeviceDescriptor {
|
|
required_features: Features::TIMESTAMP_QUERY,
|
|
..Default::default()
|
|
}))
|
|
.ok()?;
|
|
let period = queue.get_timestamp_period();
|
|
Some((device, queue, period))
|
|
}
|
|
|
|
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![],
|
|
}
|
|
}
|
|
|
|
/// A chain `depth` slots long, and instances that all resolve through its end.
|
|
fn fill(ui: &mut UiData, render: &mut UiRenderState, depth: usize) {
|
|
let kind = ui.primitives.kind::<RectPrimitive>();
|
|
let id = ui.widgets.add_strong(Rect::new(UiColor::WHITE)).id();
|
|
|
|
let mut slot = MoveIdx::NONE;
|
|
for _ in 0..depth {
|
|
slot = render.moves.push(slot, UiRegion::FULL);
|
|
}
|
|
|
|
let px = |v: f32| UiScalar::px(v);
|
|
for i in 0..INSTANCES {
|
|
let x = (i % (SIZE as usize / 2)) as f32 * 2.0;
|
|
let y = (i / (SIZE as usize / 2)) as f32;
|
|
render.layers.write(
|
|
0,
|
|
PrimitiveInst {
|
|
kind,
|
|
id,
|
|
primitive: RectPrimitive::color(UiColor::WHITE),
|
|
region: UiRegion::new(
|
|
UiSpan::new(px(x), px(x + 2.0)),
|
|
UiSpan::new(px(y), px(y + 1.0)),
|
|
),
|
|
mask_idx: MaskIdx::NONE,
|
|
move_idx: slot,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Nanoseconds the pass took on the GPU, best of `BATCHES`.
|
|
fn pass_cost(device: &Device, queue: &Queue, period: f32, depth: usize) -> f64 {
|
|
let format = TextureFormat::Bgra8Unorm;
|
|
let mut node = UiRenderNode::new(device, &config(format));
|
|
let mut ui = UiData::default();
|
|
let mut render = UiRenderState::new();
|
|
fill(&mut ui, &mut render, depth);
|
|
node.update(device, queue, &mut ui, &mut render);
|
|
|
|
let target = device.create_texture(&TextureDescriptor {
|
|
label: Some("chain 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 queries = device.create_query_set(&QuerySetDescriptor {
|
|
label: Some("chain cost"),
|
|
ty: QueryType::Timestamp,
|
|
count: 2,
|
|
});
|
|
let resolved = device.create_buffer(&BufferDescriptor {
|
|
label: Some("resolved"),
|
|
size: 16,
|
|
usage: BufferUsages::QUERY_RESOLVE | BufferUsages::COPY_SRC,
|
|
mapped_at_creation: false,
|
|
});
|
|
let readback = device.create_buffer(&BufferDescriptor {
|
|
label: Some("readback"),
|
|
size: 16,
|
|
usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST,
|
|
mapped_at_creation: false,
|
|
});
|
|
|
|
let frame = || {
|
|
let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor::default());
|
|
{
|
|
let pass = &mut encoder.begin_render_pass(&RenderPassDescriptor {
|
|
label: None,
|
|
color_attachments: &[Some(RenderPassColorAttachment {
|
|
view: &view,
|
|
resolve_target: None,
|
|
ops: Operations {
|
|
load: LoadOp::Clear(GpuColor::BLACK),
|
|
store: StoreOp::Store,
|
|
},
|
|
depth_slice: None,
|
|
})],
|
|
depth_stencil_attachment: None,
|
|
timestamp_writes: Some(RenderPassTimestampWrites {
|
|
query_set: &queries,
|
|
beginning_of_pass_write_index: Some(0),
|
|
end_of_pass_write_index: Some(1),
|
|
}),
|
|
occlusion_query_set: None,
|
|
multiview_mask: None,
|
|
});
|
|
node.draw(pass);
|
|
}
|
|
encoder.resolve_query_set(&queries, 0..2, &resolved, 0);
|
|
encoder.copy_buffer_to_buffer(&resolved, 0, &readback, 0, 16);
|
|
queue.submit(Some(encoder.finish()));
|
|
|
|
let slice = readback.slice(..);
|
|
slice.map_async(MapMode::Read, |_| {});
|
|
let _ = device.poll(PollType::Wait {
|
|
submission_index: None,
|
|
timeout: None,
|
|
});
|
|
let ns = {
|
|
let view = slice.get_mapped_range().expect("timestamps did not map");
|
|
let stamps: [u64; 2] = [
|
|
u64::from_le_bytes(view[..8].try_into().unwrap()),
|
|
u64::from_le_bytes(view[8..16].try_into().unwrap()),
|
|
];
|
|
(stamps[1].saturating_sub(stamps[0])) as f64 * period as f64
|
|
};
|
|
readback.unmap();
|
|
ns
|
|
};
|
|
|
|
frame();
|
|
let mut best = f64::MAX;
|
|
for _ in 0..BATCHES {
|
|
let mut total = 0.0;
|
|
for _ in 0..FRAMES {
|
|
total += frame();
|
|
}
|
|
best = best.min(total / FRAMES as f64);
|
|
}
|
|
best
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "measurement, not a check"]
|
|
fn chain_cost_by_depth() {
|
|
let Some((device, queue, period)) = gpu() else {
|
|
println!("no gpu with timestamps; nothing measured");
|
|
return;
|
|
};
|
|
println!("{INSTANCES} instances, {SIZE}x{SIZE}, best of {BATCHES} batches");
|
|
let mut base = None;
|
|
for depth in [1, 2, 4, 8, 16, 32, 64] {
|
|
let ns = pass_cost(&device, &queue, period, depth);
|
|
let base = *base.get_or_insert(ns);
|
|
println!(
|
|
"depth {depth:>3}: {:>9.1} us {:+6.1}% against depth 1",
|
|
ns / 1000.0,
|
|
(ns - base) / base * 100.0
|
|
);
|
|
}
|
|
}
|