Merge branch 'worktree-agent-aa9f4356c13893373' into master
# Conflicts: # docs/traceability.md
This commit is contained in:
@@ -0,0 +1,692 @@
|
||||
//! What a frame actually costs — the measurement FR-DSP-2 is waiting on.
|
||||
//!
|
||||
//! `docs/display-and-extension.md` §2 argues that tiled computation predates
|
||||
//! the fused-shader design and may not need to exist: the composer folds every
|
||||
//! active operation into **one dispatch over a viewport-sized target**, so the
|
||||
//! problem tiles were invented to solve may already be solved. That argument
|
||||
//! is only worth as much as the numbers behind it, and the decision rule was
|
||||
//! fixed in advance — if the 99th percentile sits inside 16 ms, FR-DSP-2 is
|
||||
//! rewritten as a scheduling concern for export rather than implemented on the
|
||||
//! interactive path.
|
||||
//!
|
||||
//! This is the instrument that decides it. Three measurements:
|
||||
//!
|
||||
//! - **M1** — the fused pass at proxy resolution, at three viewport sizes and
|
||||
//! three chain lengths.
|
||||
//! - **M2** — the same with the view zoomed to 1:1 on a 60 MP source, which is
|
||||
//! the case FR-DSP-5 names.
|
||||
//! - **M3** — the neighbourhood stage on its own. It is the only part of the
|
||||
//! chain that is not one read and one write, so it is the plausible
|
||||
//! budget-breaker and deserves to be measured apart from the fused pass
|
||||
//! rather than hidden inside its total.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run --release -p dr-gpu --example frame_budget
|
||||
//! ```
|
||||
//!
|
||||
//! **Release, always.** A debug build measures rustc's shadow, not the GPU's:
|
||||
//! the per-frame CPU half is dominated by shader-source assembly, which is
|
||||
//! string formatting and is several times slower unoptimised.
|
||||
//!
|
||||
//! The committed numbers live in `docs/frame-budget.md`. Rerun this and diff
|
||||
//! that file; a regression should be a diff rather than somebody's memory.
|
||||
//!
|
||||
//! # Why the 99th percentile and not the mean
|
||||
//!
|
||||
//! A slider drag is judged by its worst frame. A chain averaging 4 ms with one
|
||||
//! frame in fifty at 30 ms reads as a stutter, and the mean says nothing about
|
||||
//! it. Percentiles are nearest-rank over the samples with the warm-up already
|
||||
//! discarded — see [`Percentiles`].
|
||||
//!
|
||||
//! # What is being timed
|
||||
//!
|
||||
//! Each frame is `compose` (CPU: assemble the WGSL and its uniforms) followed
|
||||
//! by `render_detailed` and a `poll` that waits for the device to go idle.
|
||||
//! Waiting serialises the GPU work into the frame it belongs to, which is
|
||||
//! pessimistic — a real presentation pipeline overlaps a frame's tail with the
|
||||
//! next frame's head — and pessimistic is the right direction for a budget.
|
||||
//!
|
||||
//! The compose half is reported separately because it is *not* GPU work and
|
||||
//! would otherwise be invisible: it happens on the UI thread on every slider
|
||||
//! event, and if it were the expensive half then tiling could not help at all.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use dr_film::bake::{bake, Recipe};
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||
use dr_pipeline::descriptor::ParamKind;
|
||||
use dr_pipeline::ops::{
|
||||
blacks_whites, contrast, exposure, highlights_shadows, local_contrast, noise_reduction,
|
||||
vibrance, FilmTables,
|
||||
};
|
||||
use dr_pipeline::{Attribute, CropRect, EditGraph, OpId, ParamId};
|
||||
|
||||
/// The synthetic source: 9504 × 6336 is 60.2 MP, which is a Sony A7R V or a
|
||||
/// Fujifilm GFX 100 with a little taken off. §2's M2 names 60 MP, so this is
|
||||
/// that number rather than a round one.
|
||||
const SOURCE: (u32, u32) = (9504, 6336);
|
||||
|
||||
/// Frames measured per row, after the warm-up. Enough that the 99th percentile
|
||||
/// means something (nearest-rank picks the second-worst of 100) without the
|
||||
/// whole matrix taking minutes.
|
||||
const FRAMES: usize = 100;
|
||||
|
||||
/// Frames discarded before measurement begins.
|
||||
///
|
||||
/// The first frame at a new size allocates a render target, the first frame
|
||||
/// with a new chain compiles a pipeline, and the first frame of a detail stage
|
||||
/// allocates its ping-pong pair. None of those recur during a drag, so
|
||||
/// including them would measure the wrong thing — but they are worth knowing
|
||||
/// about, so [`Run::first_frame_ms`] reports the very first one separately.
|
||||
const WARMUP: usize = 12;
|
||||
|
||||
/// The viewport sizes from §2's M1: a laptop panel, a 16:10 desktop, and 4K.
|
||||
const SIZES: [(u32, u32); 3] = [(1920, 1200), (2560, 1600), (3840, 2160)];
|
||||
|
||||
/// The frame budget FR-DSP-3 is asserting against, in milliseconds. 60 Hz.
|
||||
const BUDGET_MS: f64 = 16.0;
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
|
||||
let ctx = match pollster::block_on(GpuContext::new_headless()) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("no GPU adapter: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
println!("adapter {} ({:?})", ctx.adapter_name(), ctx.backend());
|
||||
|
||||
let limit = DemosaicedImage::max_dimension(&ctx);
|
||||
if SOURCE.0 > limit {
|
||||
eprintln!(
|
||||
"this adapter caps textures at {limit} px; {} × {} will not fit",
|
||||
SOURCE.0, SOURCE.1
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let t = Instant::now();
|
||||
let source = synthetic_source(&ctx);
|
||||
println!(
|
||||
"source {} × {} ({:.1} MP, {:.0} MB as rgba16f), built in {:.1} s",
|
||||
SOURCE.0,
|
||||
SOURCE.1,
|
||||
(SOURCE.0 as f64 * SOURCE.1 as f64) / 1e6,
|
||||
(SOURCE.0 as f64 * SOURCE.1 as f64 * 8.0) / 1e6,
|
||||
t.elapsed().as_secs_f64()
|
||||
);
|
||||
println!("budget {BUDGET_MS:.0} ms (FR-DSP-3)\n");
|
||||
|
||||
let mut adjust = AdjustPass::new(&ctx);
|
||||
|
||||
m1_and_m2(&ctx, &mut adjust, &source);
|
||||
m3(&ctx, &mut adjust, &source);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// M1 and M2 — the fused pass, fit and at 1:1
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The chain lengths §2 asks for.
|
||||
///
|
||||
/// "Every operation" means every operation in [`EditGraph::default_chain`],
|
||||
/// which is what `ops/*.yaml` generates. The lens corrections are not in it —
|
||||
/// they are constructed from a matched profile rather than being part of the
|
||||
/// default chain — and neither are masks or spot repairs, which are stacks of
|
||||
/// their own. So this is an upper bound on the *declared* chain and a lower
|
||||
/// bound on the worst edit a photograph can carry; §7 of the spec asks for
|
||||
/// honesty about exactly this sort of thing.
|
||||
///
|
||||
/// [`Chain::AllPoint`] is not in §2's list and is the row that decides the
|
||||
/// question. §2 asks whether *one dispatch over a viewport-sized target* is
|
||||
/// fast enough; "every operation" mixes that dispatch together with the
|
||||
/// neighbourhood stage, which is not one dispatch and is measured separately in
|
||||
/// M3. Splitting the two apart is what lets M1 answer the question it was
|
||||
/// written to answer instead of a different, harder one.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Chain {
|
||||
One,
|
||||
Five,
|
||||
/// Every operation that contributes a fragment to the fused shader — the
|
||||
/// full point-operation chain, and nothing with a kernel.
|
||||
AllPoint,
|
||||
Everything,
|
||||
}
|
||||
|
||||
impl Chain {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Chain::One => "one",
|
||||
Chain::Five => "five",
|
||||
Chain::AllPoint => "point",
|
||||
Chain::Everything => "all",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn m1_and_m2(ctx: &GpuContext, adjust: &mut AdjustPass, source: &DemosaicedImage) {
|
||||
for (title, zoomed) in [
|
||||
(
|
||||
"M1 — fused frame at proxy resolution (fit; the develop view)",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"M2 — the same view zoomed to 1:1 on the 60 MP source (FR-DSP-5)",
|
||||
true,
|
||||
),
|
||||
] {
|
||||
println!("{title}");
|
||||
header();
|
||||
for size in SIZES {
|
||||
for chain in [Chain::One, Chain::Five, Chain::AllPoint, Chain::Everything] {
|
||||
let mut graph = build_chain(chain);
|
||||
if zoomed {
|
||||
graph.framing_mut().set_view(one_to_one(size));
|
||||
}
|
||||
if matches!(chain, Chain::AllPoint | Chain::Everything) {
|
||||
adjust.set_film(Some(&film_tables()));
|
||||
}
|
||||
// The drag: exposure, moved by a hundredth of a stop per frame.
|
||||
// A real slider does exactly this, and it is what stops the
|
||||
// fused dispatch being skipped by `render_detailed`'s colour
|
||||
// reuse — which would turn M1 into a measurement of the detail
|
||||
// stage by accident.
|
||||
let run = measure(ctx, adjust, source, &mut graph, size, |g, i| {
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 0.30 + i as f32 * 0.01);
|
||||
});
|
||||
adjust.set_film(None);
|
||||
row(size, chain.label(), &run);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
println!(" `shader` is `EditGraph::compose` alone; `cpu` adds the detail chain and the");
|
||||
println!(" invalidation hash, which is everything the develop view does per frame before");
|
||||
println!(" it dispatches. `gpu` is submit plus wait-for-idle. `TOTAL` ranks cpu + gpu");
|
||||
println!(" summed within each frame — the column the {BUDGET_MS:.0} ms budget is judged on.");
|
||||
println!();
|
||||
println!(" `point` is every operation that contributes a fragment to the fused shader;");
|
||||
println!(" `all` is that plus the four neighbourhood operations, which is why the two");
|
||||
println!(" differ by roughly what M3 charges for the detail stage on its own.");
|
||||
println!();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// M3 — the detail stage alone
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Neighbourhood operations, timed with the fused dispatch deliberately reused.
|
||||
///
|
||||
/// `render_detailed` skips the fused pass when the colour key, the shader, its
|
||||
/// uniforms and the size are all unchanged — that is FR-DEV-3d, and it is also
|
||||
/// the lever that isolates M3: move only a detail operation's amount and the
|
||||
/// timing covers the convolutions and nothing else. The `colour` column proves
|
||||
/// the isolation held rather than asserting it: it counts fused dispatches over
|
||||
/// the measured frames, and a zero there is what makes the number mean "detail
|
||||
/// alone".
|
||||
fn m3(ctx: &GpuContext, adjust: &mut AdjustPass, source: &DemosaicedImage) {
|
||||
println!("M3 — the neighbourhood stage alone (fused dispatch reused; FR-DEV-3d)");
|
||||
println!(
|
||||
" {:>11} {:>8} {:>5} {:>4} {:>6} {:>6} {:>8} {:>7} {:>7} {:>7}",
|
||||
"size", "stage", "view", "pass", "radius", "colour", "cpu p99", "p50", "p99", "max"
|
||||
);
|
||||
|
||||
for size in SIZES {
|
||||
for zoomed in [false, true] {
|
||||
for (label, build) in DETAIL_CHAINS {
|
||||
let mut graph = build();
|
||||
if zoomed {
|
||||
graph.framing_mut().set_view(one_to_one(size));
|
||||
}
|
||||
let composed = graph.compose_detail(source.size(), size);
|
||||
let (passes, radius) = (composed.len(), composed.radius());
|
||||
// Only the detail parameter moves, so the colour half of the
|
||||
// chain is bit-identical frame to frame and gets reused.
|
||||
let run = measure(ctx, adjust, source, &mut graph, size, |g, i| {
|
||||
let drift = (i % 20) as f32;
|
||||
g.set_param(
|
||||
local_contrast::CLARITY,
|
||||
local_contrast::AMOUNT,
|
||||
60.0 + drift,
|
||||
);
|
||||
g.set_param(
|
||||
local_contrast::TEXTURE,
|
||||
local_contrast::AMOUNT,
|
||||
60.0 + drift,
|
||||
);
|
||||
g.set_param(
|
||||
noise_reduction::ID,
|
||||
noise_reduction::LUMINANCE,
|
||||
60.0 + drift,
|
||||
);
|
||||
g.set_param(noise_reduction::ID, noise_reduction::CHROMA, 60.0 + drift);
|
||||
});
|
||||
println!(
|
||||
" {:>5}x{:<5} {:>8} {:>5} {:>4} {:>6} {:>6} {:>6.2}ms {:>5.2}ms {:>5.2}ms {:>5.2}ms",
|
||||
size.0,
|
||||
size.1,
|
||||
label,
|
||||
if zoomed { "1:1" } else { "fit" },
|
||||
passes,
|
||||
radius,
|
||||
run.colour_dispatches,
|
||||
run.cpu.p99,
|
||||
run.frame.p50,
|
||||
run.frame.p99,
|
||||
run.frame.max
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!();
|
||||
println!(" `pass` is dispatches in the detail chain and `radius` the widest halo any of");
|
||||
println!(" them reads, in render pixels. `colour` is fused dispatches over the {FRAMES}");
|
||||
println!(" measured frames, and must be 0 for the row to mean what it says.");
|
||||
println!();
|
||||
println!(" Clarity's kernel is a fraction of the *frame*, so it grows with the viewport");
|
||||
println!(" and not with the zoom. Noise reduction's is a fraction of the *sensor*, so it");
|
||||
println!(" grows with the zoom and not with the viewport. That is why both are here.");
|
||||
}
|
||||
|
||||
/// The detail chains M3 walks: the widest kernel alone, then all four
|
||||
/// neighbourhood operations at once.
|
||||
///
|
||||
/// Clarity is first because §2 names it — "a separable blur at a large radius
|
||||
/// is the plausible budget-breaker" — and its σ is 1.2% of the shorter edge,
|
||||
/// which at 4K is a 52-pixel radius and the widest kernel anywhere in the
|
||||
/// pipeline.
|
||||
/// A named detail chain: a label for the table, and the graph it builds.
|
||||
type DetailChain = (&'static str, fn() -> EditGraph);
|
||||
|
||||
const DETAIL_CHAINS: [DetailChain; 2] = [
|
||||
("clarity", || {
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(local_contrast::CLARITY, local_contrast::AMOUNT, 60.0);
|
||||
g
|
||||
}),
|
||||
("all four", || {
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(local_contrast::CLARITY, local_contrast::AMOUNT, 60.0);
|
||||
g.set_param(local_contrast::TEXTURE, local_contrast::AMOUNT, 60.0);
|
||||
g.set_param(noise_reduction::ID, noise_reduction::LUMINANCE, 60.0);
|
||||
g.set_param(noise_reduction::ID, noise_reduction::CHROMA, 60.0);
|
||||
g.set_param(
|
||||
dr_pipeline::ops::capture_sharpen::ID,
|
||||
dr_pipeline::ops::capture_sharpen::AMOUNT,
|
||||
60.0,
|
||||
);
|
||||
g
|
||||
}),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The measurement itself
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Nearest-rank percentiles over a set of frame times, in milliseconds.
|
||||
///
|
||||
/// Nearest-rank rather than an interpolating definition because the samples
|
||||
/// *are* the population — there is no distribution being estimated, only a
|
||||
/// hundred frames that either happened inside the budget or did not. At
|
||||
/// `FRAMES = 100` the 99th percentile is the second-worst frame, which is the
|
||||
/// honest reading of "one stutter in a hundred is one too many" without
|
||||
/// letting a single scheduler hiccup on an unrelated process decide the
|
||||
/// verdict.
|
||||
struct Percentiles {
|
||||
p50: f64,
|
||||
p99: f64,
|
||||
max: f64,
|
||||
}
|
||||
|
||||
impl Percentiles {
|
||||
fn of(mut samples: Vec<f64>) -> Self {
|
||||
samples.sort_by(f64::total_cmp);
|
||||
let rank = |p: f64| {
|
||||
let n = samples.len();
|
||||
let i = ((p * n as f64).ceil() as usize).clamp(1, n) - 1;
|
||||
samples[i]
|
||||
};
|
||||
Self {
|
||||
p50: rank(0.50),
|
||||
p99: rank(0.99),
|
||||
max: samples[samples.len() - 1],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Run {
|
||||
/// CPU: assembling the fused shader alone.
|
||||
shader: Percentiles,
|
||||
/// CPU: everything `DevelopSession::render` does before it dispatches —
|
||||
/// the fused shader, the detail chain, and the invalidation hash the
|
||||
/// colour key comes from. Split out from [`Self::shader`] because if the
|
||||
/// expensive half of a frame turns out to be string formatting on the UI
|
||||
/// thread, no amount of tiling helps and the conclusion is a different
|
||||
/// one.
|
||||
cpu: Percentiles,
|
||||
/// Submit plus wait for the device to go idle.
|
||||
frame: Percentiles,
|
||||
/// CPU and GPU summed **per frame**, then ranked.
|
||||
///
|
||||
/// Not the sum of the two percentiles above, which would be a number no
|
||||
/// frame ever took: the CPU's worst frame and the GPU's worst frame are
|
||||
/// not generally the same frame, and adding them invents a stutter that
|
||||
/// did not happen. This is the column the budget is judged on.
|
||||
total: Percentiles,
|
||||
/// The very first frame of all, warm-up included — pipeline compilation
|
||||
/// and target allocation. Reported because it is real (it is what a
|
||||
/// photograph opening costs) and because it must not be inside the
|
||||
/// percentiles.
|
||||
first_frame_ms: f64,
|
||||
/// Fused dispatches over the measured frames. `FRAMES` when the colour
|
||||
/// chain is moving, 0 when only a detail parameter is.
|
||||
colour_dispatches: usize,
|
||||
}
|
||||
|
||||
/// Render `FRAMES` frames, moving a parameter between each, and time them.
|
||||
///
|
||||
/// `drag` receives the frame index and is expected to move whatever this row
|
||||
/// is measuring the drag of. It is called for the warm-up frames too, so that
|
||||
/// nothing measured is the first of its kind.
|
||||
fn measure(
|
||||
ctx: &GpuContext,
|
||||
adjust: &mut AdjustPass,
|
||||
source: &DemosaicedImage,
|
||||
graph: &mut EditGraph,
|
||||
size: (u32, u32),
|
||||
drag: impl Fn(&mut EditGraph, usize),
|
||||
) -> Run {
|
||||
// A constant, because every row here renders the same photograph. In the
|
||||
// app this is the `VersionId` mixed with the colour invalidation — see
|
||||
// `render_detailed`'s note on why the caller owns it.
|
||||
const COLOUR_KEY: u64 = 0x0dd_ba11;
|
||||
|
||||
let src = source.size();
|
||||
let mut first_frame_ms = f64::NAN;
|
||||
|
||||
for i in 0..WARMUP {
|
||||
drag(graph, i);
|
||||
let t = Instant::now();
|
||||
frame(ctx, adjust, source, graph, src, size, COLOUR_KEY);
|
||||
if i == 0 {
|
||||
first_frame_ms = t.elapsed().as_secs_f64() * 1e3;
|
||||
}
|
||||
}
|
||||
|
||||
let dispatches_before = adjust.colour_dispatches();
|
||||
let mut shader_ms = Vec::with_capacity(FRAMES);
|
||||
let mut cpu_ms = Vec::with_capacity(FRAMES);
|
||||
let mut gpu_ms = Vec::with_capacity(FRAMES);
|
||||
let mut total_ms = Vec::with_capacity(FRAMES);
|
||||
|
||||
for i in 0..FRAMES {
|
||||
drag(graph, WARMUP + i);
|
||||
|
||||
// The same three calls `DevelopSession::render` makes, in the same
|
||||
// order, so that this is the develop view's frame and not an
|
||||
// idealisation of it.
|
||||
let t0 = Instant::now();
|
||||
let shader = graph.compose();
|
||||
let after_shader = t0.elapsed();
|
||||
let detail = graph.compose_detail(src, size);
|
||||
let colour_key = graph.invalidation().through(dr_pipeline::Affects::Colour);
|
||||
let cpu = t0.elapsed();
|
||||
|
||||
let t1 = Instant::now();
|
||||
adjust
|
||||
.render_detailed(
|
||||
source,
|
||||
&shader,
|
||||
size.0,
|
||||
size.1,
|
||||
None,
|
||||
&detail,
|
||||
colour_key ^ COLOUR_KEY,
|
||||
)
|
||||
.expect("render");
|
||||
ctx.device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.expect("poll");
|
||||
let gpu = t1.elapsed();
|
||||
|
||||
shader_ms.push(after_shader.as_secs_f64() * 1e3);
|
||||
cpu_ms.push(cpu.as_secs_f64() * 1e3);
|
||||
gpu_ms.push(gpu.as_secs_f64() * 1e3);
|
||||
total_ms.push((cpu + gpu).as_secs_f64() * 1e3);
|
||||
}
|
||||
|
||||
Run {
|
||||
shader: Percentiles::of(shader_ms),
|
||||
cpu: Percentiles::of(cpu_ms),
|
||||
frame: Percentiles::of(gpu_ms),
|
||||
total: Percentiles::of(total_ms),
|
||||
first_frame_ms,
|
||||
colour_dispatches: adjust.colour_dispatches() - dispatches_before,
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame, composition included, with nothing timed. The warm-up path.
|
||||
fn frame(
|
||||
ctx: &GpuContext,
|
||||
adjust: &mut AdjustPass,
|
||||
source: &DemosaicedImage,
|
||||
graph: &EditGraph,
|
||||
src: (u32, u32),
|
||||
size: (u32, u32),
|
||||
colour_key: u64,
|
||||
) {
|
||||
let shader = graph.compose();
|
||||
let detail = graph.compose_detail(src, size);
|
||||
let key = graph.invalidation().through(dr_pipeline::Affects::Colour) ^ colour_key;
|
||||
adjust
|
||||
.render_detailed(source, &shader, size.0, size.1, None, &detail, key)
|
||||
.expect("render");
|
||||
ctx.device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.expect("poll");
|
||||
}
|
||||
|
||||
fn header() {
|
||||
println!(
|
||||
" {:>11} {:>5} {:>8} {:>8} {:>8} {:>8} {:>8} {:>7}",
|
||||
"size", "chain", "shader", "cpu p99", "gpu p50", "gpu p99", "TOTAL", "first"
|
||||
);
|
||||
}
|
||||
|
||||
fn row(size: (u32, u32), chain: &str, run: &Run) {
|
||||
println!(
|
||||
" {:>5}x{:<5} {:>5} {:>6.2}ms {:>6.2}ms {:>6.2}ms {:>6.2}ms {:>6.2}ms {:>5.0}ms{}",
|
||||
size.0,
|
||||
size.1,
|
||||
chain,
|
||||
run.shader.p99,
|
||||
run.cpu.p99,
|
||||
run.frame.p50,
|
||||
run.frame.p99,
|
||||
run.total.p99,
|
||||
run.first_frame_ms,
|
||||
if run.total.p99 > BUDGET_MS {
|
||||
" OVER"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The view rect that puts one render pixel on one source pixel.
|
||||
///
|
||||
/// This is FR-DSP-5's mechanism stated as arithmetic: the render target keeps
|
||||
/// its size while the sampled region shrinks, so a view covering exactly
|
||||
/// `render / source` of the frame samples one-for-one. Nothing anywhere
|
||||
/// switches to a "full resolution path"; the zoom *is* the full-resolution
|
||||
/// path.
|
||||
fn one_to_one(render: (u32, u32)) -> CropRect {
|
||||
let w = render.0 as f32 / SOURCE.0 as f32;
|
||||
let h = render.1 as f32 / SOURCE.1 as f32;
|
||||
CropRect {
|
||||
// Centred, so the sampled region is somewhere a person would actually
|
||||
// look and not a corner the caches treat differently.
|
||||
x: (1.0 - w) * 0.5,
|
||||
y: (1.0 - h) * 0.5,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
/// A 60 MP source with detail at every scale.
|
||||
///
|
||||
/// Not flat and not noise. A flat frame lets the memory system serve every
|
||||
/// sample from one cache line, which flatters the wide kernels of M3 by an
|
||||
/// amount that has nothing to do with photographs; pure noise does the
|
||||
/// opposite. This is a coarse gradient with a fine dither on top, which is
|
||||
/// closer to a real frame's spectrum than either and costs one multiply per
|
||||
/// pixel to generate.
|
||||
fn synthetic_source(ctx: &GpuContext) -> DemosaicedImage {
|
||||
let (w, h) = SOURCE;
|
||||
let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4];
|
||||
for y in 0..h as usize {
|
||||
let row = y * (w as usize) * 4;
|
||||
for x in 0..w as usize {
|
||||
// A cheap integer hash for the fine structure, so neighbouring
|
||||
// pixels differ and a bilateral filter has something to reject.
|
||||
let n = (x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(1_640_531_527)) >> 13;
|
||||
let dither = (n & 0x1f) as u32;
|
||||
let gx = (x * 200 / w as usize) as u32;
|
||||
let gy = (y * 55 / h as usize) as u32;
|
||||
let px = &mut rgba[row + x * 4..row + x * 4 + 4];
|
||||
px[0] = (30 + gx + dither).min(255) as u8;
|
||||
px[1] = (40 + gy + dither).min(255) as u8;
|
||||
px[2] = (60 + gx / 2 + gy + dither).min(255) as u8;
|
||||
px[3] = 255;
|
||||
}
|
||||
}
|
||||
DemosaicedImage::from_rgba8(ctx, &rgba, w, h).expect("upload source")
|
||||
}
|
||||
|
||||
fn build_chain(chain: Chain) -> EditGraph {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
match chain {
|
||||
Chain::One => {
|
||||
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.3);
|
||||
}
|
||||
Chain::Five => {
|
||||
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.3);
|
||||
graph.set_param(contrast::ID, contrast::CONTRAST, 25.0);
|
||||
graph.set_param(
|
||||
highlights_shadows::ID,
|
||||
highlights_shadows::HIGHLIGHTS,
|
||||
-40.0,
|
||||
);
|
||||
graph.set_param(blacks_whites::ID, blacks_whites::BLACKS, -20.0);
|
||||
graph.set_param(vibrance::ID, vibrance::VIBRANCE, 35.0);
|
||||
}
|
||||
Chain::AllPoint => {
|
||||
activate_everything(&mut graph, false);
|
||||
}
|
||||
Chain::Everything => {
|
||||
activate_everything(&mut graph, true);
|
||||
}
|
||||
}
|
||||
graph
|
||||
}
|
||||
|
||||
/// Move every parameter of every operation off its default.
|
||||
///
|
||||
/// Written against [`EditGraph::capabilities`] rather than as a list of
|
||||
/// operations, for the same reason the panel is: this bench must not need
|
||||
/// editing when an operation is added, or it will quietly stop measuring "all
|
||||
/// of them" on the first day somebody declares a new node.
|
||||
///
|
||||
/// A quarter of the way from the default towards the maximum, which is a
|
||||
/// setting a photographer might plausibly reach and — far more importantly —
|
||||
/// is *active*, since an operation sitting at its neutral contributes no
|
||||
/// fragment at all and would make this row a shorter chain wearing a longer
|
||||
/// chain's label.
|
||||
///
|
||||
/// `neighbourhood` selects whether the operations declaring
|
||||
/// [`Attribute::Detail`] are moved as well. Left off, what remains is exactly
|
||||
/// the set that contributes a fragment to the fused shader — see [`Chain`] for
|
||||
/// why that distinction is the point of this bench.
|
||||
fn activate_everything(graph: &mut EditGraph, neighbourhood: bool) {
|
||||
let moves: Vec<(OpId, ParamId, f32)> = graph
|
||||
.capabilities()
|
||||
.iter()
|
||||
.filter(|op| neighbourhood || !op.attributes.contains(&Attribute::Detail))
|
||||
.flat_map(|op| {
|
||||
op.params.iter().map(move |p| {
|
||||
let value = match p.kind {
|
||||
ParamKind::Scalar { min, max, .. } => {
|
||||
// Towards whichever end is further away, so a
|
||||
// parameter defaulting to its maximum still moves.
|
||||
let far = if (max - p.default).abs() >= (p.default - min).abs() {
|
||||
max
|
||||
} else {
|
||||
min
|
||||
};
|
||||
p.default + (far - p.default) * 0.25
|
||||
}
|
||||
ParamKind::Bool => 1.0,
|
||||
// The second variant when there is one. The first is the
|
||||
// default by construction — see `ParamDescriptor::choice`.
|
||||
ParamKind::Enum { variants } => {
|
||||
if variants.len() > 1 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
};
|
||||
(op.id, p.id, value)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for (op, param, value) in moves {
|
||||
graph.set_param(op, param, value);
|
||||
}
|
||||
|
||||
// The film stock is not a slider and so is not reachable through the loop
|
||||
// above: `FilmSim::is_active` is true when it has tables and false
|
||||
// otherwise (see `graph::Film`). Without this the "all" row would be the
|
||||
// whole chain minus its single most expensive node, which is a 3D lookup
|
||||
// and a pair of curve textures.
|
||||
graph.set_film(Some(dr_pipeline::graph::Film {
|
||||
stock: STOCK.into(),
|
||||
print: None,
|
||||
tables: film_tables(),
|
||||
}));
|
||||
}
|
||||
|
||||
/// The stock the "every operation" chain renders through. A colour negative
|
||||
/// viewed directly, which is the more expensive of the two paths: the LUT is
|
||||
/// consulted either way, and skipping the print step is one fewer thing that
|
||||
/// could be mistaken for the measurement.
|
||||
const STOCK: &str = "kodak_portra_400";
|
||||
|
||||
/// The stock the "all operations" row renders through, in the layout the pass
|
||||
/// binds. Baked once per row; baking is milliseconds and happens off the frame
|
||||
/// path in the app too.
|
||||
fn film_tables() -> FilmTables {
|
||||
let film = dr_film::find(STOCK).expect("stock");
|
||||
let baked = bake(&Recipe::new(film, None));
|
||||
FilmTables {
|
||||
exposure_matrix: baked.exposure_matrix,
|
||||
curves: baked.curves.clone(),
|
||||
curve_log_min: baked.curve_log_min,
|
||||
curve_log_max: baked.curve_log_max,
|
||||
lut: baked.lut.clone(),
|
||||
density_max: baked.density_max,
|
||||
lut_size: baked.lut_size,
|
||||
// Grain off. It is a per-pixel hash and would be measured; it is also
|
||||
// not part of every edit, and the chain being measured here is "every
|
||||
// operation active", not "every option of every operation".
|
||||
grain_particles: [0.0; 3],
|
||||
grain_density_max: [baked.density_max; 3],
|
||||
grain_uniformity: 0.97,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//! The frame budget, asserted rather than hoped for.
|
||||
//!
|
||||
//! FR-DSP-3 says a slider updates the visible region within one frame budget at
|
||||
//! proxy resolution. Until this file existed nothing checked it, which made it
|
||||
//! a wish — `docs/display-and-extension.md` §3 is blunt about that, and §7 is
|
||||
//! blunt about what tagging an unchecked requirement does to the coverage
|
||||
//! figure.
|
||||
//!
|
||||
//! The measurements this guards are in [`docs/frame-budget.md`], produced by
|
||||
//! `examples/frame_budget.rs`. This file is the part of them that has to keep
|
||||
//! being true: it renders the **whole point-operation chain** through the real
|
||||
//! `render_detailed` for a hundred frames, moving a slider between each, and
|
||||
//! fails if the 99th percentile leaves the budget.
|
||||
//!
|
||||
//! # What it does not cover, said out loud
|
||||
//!
|
||||
//! **The neighbourhood stage is deliberately not in the asserted chain.** It is
|
||||
//! over the budget today — clarity alone is 34 ms at 4K, because its kernel is
|
||||
//! a fraction of the frame and reaches a 52-pixel radius there — and
|
||||
//! `docs/frame-budget.md` records that, names the fix (a base computed at
|
||||
//! reduced resolution) and does not pretend otherwise. Asserting a budget the
|
||||
//! code does not meet would produce a red suite that everyone learns to ignore;
|
||||
//! asserting it on a chain that quietly excluded the expensive stage *without
|
||||
//! saying so* would be the coverage overstatement §7 warns about. So it is
|
||||
//! excluded, loudly, here.
|
||||
//!
|
||||
//! What is asserted is exactly the claim the FR-DSP-2 recommendation rests on:
|
||||
//! that **one fused dispatch over a viewport-sized target is comfortably inside
|
||||
//! the budget**, at a full chain, fit and at 1:1. If that stops being true, the
|
||||
//! recommendation to strike tiled computation from the interactive path stops
|
||||
//! being supported, and this test is what says so.
|
||||
//!
|
||||
//! # Why the percentile and not the mean
|
||||
//!
|
||||
//! A drag is judged by its worst frame. Nearest-rank over 100 frames puts the
|
||||
//! 99th percentile at the second-worst, which is strict enough to catch a
|
||||
//! stutter and forgiving enough that one scheduler hiccup from an unrelated
|
||||
//! process does not decide the verdict.
|
||||
//!
|
||||
//! # Why the CPU half is asserted only in an optimised build
|
||||
//!
|
||||
//! Composing the shader is per-frame work on the UI thread and belongs in the
|
||||
//! budget — `DevelopSession::render` calls `compose` on every frame, and on a
|
||||
//! full chain it is milliseconds of string formatting. But the workspace builds
|
||||
//! its own crates at `opt-level = 0` in dev (see the root `Cargo.toml`), and
|
||||
//! `cargo test` is a dev build, so that formatting runs unoptimised here and
|
||||
//! measures rustc rather than the pipeline. The GPU half is unaffected: a
|
||||
//! shader is compiled by the driver either way.
|
||||
//!
|
||||
//! So the GPU half is always asserted, and the composition is folded in only
|
||||
//! when `debug_assertions` is off. Running `cargo test --release -p dr-gpu`
|
||||
//! therefore checks strictly more than the default run does, and the numbers
|
||||
//! printed on failure say which of the two halves was over.
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||
use dr_pipeline::descriptor::ParamKind;
|
||||
use dr_pipeline::ops::exposure;
|
||||
use dr_pipeline::{Affects, Attribute, CropRect, EditGraph, OpId, ParamId};
|
||||
use std::time::Instant;
|
||||
|
||||
/// 60 Hz. FR-DSP-3 does not name a number; this is the one every interactive
|
||||
/// application means by "one frame".
|
||||
const BUDGET_MS: f64 = 16.0;
|
||||
|
||||
/// Measured frames per case. Nearest-rank p99 of 100 is the second-worst.
|
||||
const FRAMES: usize = 100;
|
||||
|
||||
/// Discarded before measurement: the first frame at a size allocates a render
|
||||
/// target and the first frame of a chain compiles a pipeline. Neither recurs
|
||||
/// during a drag, so neither belongs in a drag's percentile.
|
||||
const WARMUP: usize = 12;
|
||||
|
||||
/// A 24 MP source — a full-frame camera, and large enough that a 1:1 view of it
|
||||
/// is a genuine zoom rather than a rounding error.
|
||||
///
|
||||
/// Smaller than the bench's 60 MP on purpose. The fused pass costs what the
|
||||
/// *output* costs, so the source size barely moves these numbers, and 24 MP
|
||||
/// keeps the fixture inside a second even at `opt-level = 0`.
|
||||
const SOURCE: (u32, u32) = (6000, 4000);
|
||||
|
||||
/// The viewport the budget is asserted at: a 16:10 desktop display.
|
||||
///
|
||||
/// Not 4K, and the reason is worth stating. At 4K the fused chain still passes
|
||||
/// with room to spare (4.5 ms of GPU; see `docs/frame-budget.md`), but a test
|
||||
/// that renders 8.3 M pixels a hundred times twice over is four seconds of
|
||||
/// suite time to re-establish a conclusion 4.1 M pixels already establishes.
|
||||
const VIEWPORT: (u32, u32) = (2560, 1600);
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
// CI runners and headless machines may have no usable adapter. Skip rather
|
||||
// than fail, exactly as the rest of this crate's device tests do.
|
||||
match pollster::block_on(GpuContext::new_headless()) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
eprintln!("skipping: no GPU adapter ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-3 | FR-DSP-5
|
||||
/// A slider drag on the full point-operation chain stays inside one frame —
|
||||
/// fit, and at 1:1.
|
||||
///
|
||||
/// The develop view's ordinary case, at a full chain rather than a flattering
|
||||
/// one: every operation that contributes a fragment to the fused shader is
|
||||
/// active, and exposure moves between frames exactly as a drag moves it.
|
||||
///
|
||||
/// The 1:1 case is the one FR-DSP-5 names and the one FR-DSP-3's asynchronous
|
||||
/// clause was written for. It is asserted here because the measurement found
|
||||
/// that clause unnecessary rather than merely unimplemented: a 1:1 view is
|
||||
/// *cheaper* than a fit view of the same file, since the dispatch is the same
|
||||
/// size and the reads are contiguous rather than strided. If that ever inverts,
|
||||
/// the argument for striking the clause weakens, and this is what would notice.
|
||||
///
|
||||
/// # One test and not two, deliberately
|
||||
///
|
||||
/// The two cases were two `#[test]` functions until the numbers said otherwise.
|
||||
/// Cargo runs a binary's tests on a thread each, both of these want the same
|
||||
/// GPU, and contending for it took the 1:1 case from 2.5 ms to 14.9 ms — a
|
||||
/// measurement of the test harness that would have flickered either side of the
|
||||
/// budget forever. A timing assertion has to own the device while it runs, and
|
||||
/// the only way to say that in a test binary is to be the only test in it.
|
||||
#[test]
|
||||
fn a_slider_drag_stays_inside_the_frame_budget() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = synthetic_source(&ctx);
|
||||
|
||||
let mut fit = full_point_chain();
|
||||
drag(&ctx, &source, &mut fit, VIEWPORT).assert_inside_budget("proxy resolution, fit", VIEWPORT);
|
||||
|
||||
let mut zoomed = full_point_chain();
|
||||
zoomed.framing_mut().set_view(one_to_one(VIEWPORT));
|
||||
drag(&ctx, &source, &mut zoomed, VIEWPORT)
|
||||
.assert_inside_budget("1:1 on a 24 MP source", VIEWPORT);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The measurement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Run {
|
||||
/// Per frame: compose, compose the detail chain, hash the invalidation.
|
||||
cpu_p99: f64,
|
||||
/// Per frame: submit and wait for the device to go idle.
|
||||
gpu_p99: f64,
|
||||
/// `cpu + gpu` summed within each frame, then ranked. Not the sum of the
|
||||
/// two percentiles above, which would be a frame that never happened.
|
||||
total_p99: f64,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
/// Fail if the budget was missed, saying which half missed it.
|
||||
///
|
||||
/// In a dev build only the GPU half is judged — see the module
|
||||
/// documentation for why — and the CPU figure is still printed, because a
|
||||
/// reader looking at a failure wants both numbers even when only one of
|
||||
/// them is the verdict.
|
||||
fn assert_inside_budget(&self, case: &str, viewport: (u32, u32)) {
|
||||
let judged = if cfg!(debug_assertions) {
|
||||
self.gpu_p99
|
||||
} else {
|
||||
self.total_p99
|
||||
};
|
||||
assert!(
|
||||
judged <= BUDGET_MS,
|
||||
"{case} at {}x{}: p99 of {FRAMES} frames was {judged:.2} ms, over the \
|
||||
{BUDGET_MS:.0} ms budget (cpu {:.2} ms, gpu {:.2} ms, total {:.2} ms). \
|
||||
FR-DSP-3 is what this violates; docs/frame-budget.md holds the \
|
||||
numbers it used to be.",
|
||||
viewport.0,
|
||||
viewport.1,
|
||||
self.cpu_p99,
|
||||
self.gpu_p99,
|
||||
self.total_p99,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `FRAMES` frames with the exposure slider moving between each.
|
||||
///
|
||||
/// The three calls before the dispatch are the three `DevelopSession::render`
|
||||
/// makes, in the same order, so this is the develop view's frame rather than an
|
||||
/// idealisation of it.
|
||||
fn drag(
|
||||
ctx: &GpuContext,
|
||||
source: &DemosaicedImage,
|
||||
graph: &mut EditGraph,
|
||||
viewport: (u32, u32),
|
||||
) -> Run {
|
||||
// Stands for the `VersionId` the app mixes in. Constant because every frame
|
||||
// here is the same photograph.
|
||||
const PHOTOGRAPH: u64 = 0x0dd_ba11;
|
||||
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
let src = source.size();
|
||||
let (w, h) = viewport;
|
||||
|
||||
let mut cpu = Vec::with_capacity(FRAMES);
|
||||
let mut gpu = Vec::with_capacity(FRAMES);
|
||||
let mut total = Vec::with_capacity(FRAMES);
|
||||
|
||||
for i in 0..WARMUP + FRAMES {
|
||||
// A hundredth of a stop per frame: what a drag does, and what stops
|
||||
// `render_detailed` reusing the previous frame's colour result and
|
||||
// turning this into a measurement of nothing.
|
||||
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.30 + i as f32 * 0.01);
|
||||
|
||||
let t0 = Instant::now();
|
||||
let shader = graph.compose();
|
||||
let detail = graph.compose_detail(src, viewport);
|
||||
let colour_key = graph.invalidation().through(Affects::Colour) ^ PHOTOGRAPH;
|
||||
let cpu_elapsed = t0.elapsed();
|
||||
|
||||
let t1 = Instant::now();
|
||||
adjust
|
||||
.render_detailed(source, &shader, w, h, None, &detail, colour_key)
|
||||
.expect("render");
|
||||
ctx.device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.expect("poll");
|
||||
let gpu_elapsed = t1.elapsed();
|
||||
|
||||
if i >= WARMUP {
|
||||
cpu.push(cpu_elapsed.as_secs_f64() * 1e3);
|
||||
gpu.push(gpu_elapsed.as_secs_f64() * 1e3);
|
||||
total.push((cpu_elapsed + gpu_elapsed).as_secs_f64() * 1e3);
|
||||
}
|
||||
}
|
||||
|
||||
Run {
|
||||
cpu_p99: p99(cpu),
|
||||
gpu_p99: p99(gpu),
|
||||
total_p99: p99(total),
|
||||
}
|
||||
}
|
||||
|
||||
/// Nearest-rank 99th percentile.
|
||||
///
|
||||
/// Nearest-rank because the samples *are* the population: there is no
|
||||
/// distribution being estimated, only a hundred frames that either fitted in
|
||||
/// the budget or did not.
|
||||
fn p99(mut samples: Vec<f64>) -> f64 {
|
||||
samples.sort_by(f64::total_cmp);
|
||||
let n = samples.len();
|
||||
samples[((0.99 * n as f64).ceil() as usize).clamp(1, n) - 1]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Every operation that contributes a fragment to the fused shader, active.
|
||||
///
|
||||
/// Built from [`EditGraph::capabilities`] rather than from a list of operation
|
||||
/// names, for the same reason the develop panel is: declaring a new node must
|
||||
/// not silently shrink what this test calls "the full chain". A quarter of the
|
||||
/// way from each parameter's default towards whichever end is further from it,
|
||||
/// which is a plausible setting and — the part that matters — is never the
|
||||
/// neutral, since a neutral operation contributes nothing at all.
|
||||
///
|
||||
/// The film stock is not reachable this way (it is a choice of material, not a
|
||||
/// slider) and is left off. It is one texture lookup and two curve reads; the
|
||||
/// bench includes it and it is worth about a millisecond at this size.
|
||||
fn full_point_chain() -> EditGraph {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
let moves: Vec<(OpId, ParamId, f32)> = graph
|
||||
.capabilities()
|
||||
.iter()
|
||||
// The neighbourhood operations. See the module documentation for why
|
||||
// they are not here.
|
||||
.filter(|op| !op.attributes.contains(&Attribute::Detail))
|
||||
.flat_map(|op| {
|
||||
op.params.iter().map(move |p| {
|
||||
let value = match p.kind {
|
||||
ParamKind::Scalar { min, max, .. } => {
|
||||
let far = if (max - p.default).abs() >= (p.default - min).abs() {
|
||||
max
|
||||
} else {
|
||||
min
|
||||
};
|
||||
p.default + (far - p.default) * 0.25
|
||||
}
|
||||
ParamKind::Bool => 1.0,
|
||||
ParamKind::Enum { variants } => {
|
||||
if variants.len() > 1 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
};
|
||||
(op.id, p.id, value)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for (op, param, value) in moves {
|
||||
graph.set_param(op, param, value);
|
||||
}
|
||||
graph
|
||||
}
|
||||
|
||||
/// The view rect that puts one render pixel on one source pixel, centred.
|
||||
fn one_to_one(render: (u32, u32)) -> CropRect {
|
||||
let w = render.0 as f32 / SOURCE.0 as f32;
|
||||
let h = render.1 as f32 / SOURCE.1 as f32;
|
||||
CropRect {
|
||||
x: (1.0 - w) * 0.5,
|
||||
y: (1.0 - h) * 0.5,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
/// A source with structure at every scale.
|
||||
///
|
||||
/// Not flat: a flat frame lets the memory system serve every sample of every
|
||||
/// pixel from one cache line, which flatters a bandwidth-bound pass by an amount
|
||||
/// that has nothing to do with photographs.
|
||||
fn synthetic_source(ctx: &GpuContext) -> DemosaicedImage {
|
||||
let (w, h) = SOURCE;
|
||||
let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4];
|
||||
for y in 0..h as usize {
|
||||
let row = y * (w as usize) * 4;
|
||||
for x in 0..w as usize {
|
||||
let n = (x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(1_640_531_527)) >> 13;
|
||||
let dither = (n & 0x1f) as u32;
|
||||
let gx = (x * 200 / w as usize) as u32;
|
||||
let gy = (y * 55 / h as usize) as u32;
|
||||
let px = &mut rgba[row + x * 4..row + x * 4 + 4];
|
||||
px[0] = (30 + gx + dither).min(255) as u8;
|
||||
px[1] = (40 + gy + dither).min(255) as u8;
|
||||
px[2] = (60 + gx / 2 + gy + dither).min(255) as u8;
|
||||
px[3] = 255;
|
||||
}
|
||||
}
|
||||
DemosaicedImage::from_rgba8(ctx, &rgba, w, h).expect("upload")
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! TRACES: FR-DSP-5
|
||||
//! Zooming to 1:1 samples the source, pixel for pixel.
|
||||
//!
|
||||
//! FR-DSP-5: *"Fit, 1:1, and arbitrary zoom levels. At 1:1 and above, the
|
||||
//! pipeline operates on the visible crop at full source resolution."*
|
||||
//!
|
||||
//! `Framing::view` shrinks the sampled region while the render target keeps its
|
||||
//! size, so zooming *raises* the resolution the pipeline works at rather than
|
||||
//! magnifying pixels it has already drawn. There is no second full-resolution
|
||||
//! code path — the zoom is the full-resolution path — which is why the
|
||||
//! requirement has been satisfied for some time without anyone tagging it.
|
||||
//!
|
||||
//! `docs/display-and-extension.md` §7 is the reason this file exists rather
|
||||
//! than a tag on `framing.rs`: a requirement counts as covered when a `TRACES`
|
||||
//! comment names it, and nothing checks that the code under the tag does the
|
||||
//! thing. `FR-DEV-8` is tagged against plumbing a future operation would use.
|
||||
//! So the rule that document sets is that a requirement is closed by **a test
|
||||
//! that would fail if the behaviour were removed**, and these are written to
|
||||
//! fail in exactly that case: delete the view from `Framing::visible_rect` and
|
||||
//! the 1:1 render collapses into the fit render, which
|
||||
//! [`a_proxy_cannot_resolve_the_finest_detail_in_the_source`] establishes
|
||||
//! carries none of the information the 1:1 render reproduces.
|
||||
//!
|
||||
//! # Why the fixture is alternating columns
|
||||
//!
|
||||
//! Because it makes "sampled at source resolution" a *pixel* assertion rather
|
||||
//! than a "something got sharper" one.
|
||||
//!
|
||||
//! The source is one pixel black, one pixel white, all the way across. That is
|
||||
//! the highest spatial frequency the image can hold, and it is exactly what a
|
||||
//! proxy render throws away: a 1024-wide source in a 128-wide viewport maps
|
||||
//! output column `x` to source column `8x + 4`, every one of which has the same
|
||||
//! parity, so the whole proxy comes out flat. No amount of resampling that flat
|
||||
//! image recovers the stripes. If the 1:1 render shows them — and shows them in
|
||||
//! the right phase, from the right place in the source — then it read the
|
||||
//! source and did not magnify the proxy. There is no third explanation.
|
||||
//!
|
||||
//! The source is uploaded through `DemosaicedImage::from_rgba8`, which flags it
|
||||
//! non-linear, so the fused shader decodes sRGB before the chain and re-encodes
|
||||
//! after it. Bytes 0 and 255 are fixed points of that round trip, which is why
|
||||
//! the pattern is black and white and why the comparison can be for equality
|
||||
//! rather than within a tolerance.
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||
use dr_pipeline::{CropRect, EditGraph};
|
||||
|
||||
/// A power of two, so every view rect below is exact in binary32 and the
|
||||
/// mapping from output column to source column is exact arithmetic rather than
|
||||
/// something that happens to round the right way.
|
||||
const SOURCE: u32 = 1024;
|
||||
|
||||
/// The viewport. `SOURCE / RENDER` is 8, so a fit render steps eight source
|
||||
/// columns per output column — four full periods of the pattern.
|
||||
const RENDER: u32 = 128;
|
||||
|
||||
/// Where the 1:1 window sits in the source. **Odd on both axes on purpose**: a
|
||||
/// view that honoured `width` but dropped `x` would land on the opposite phase
|
||||
/// of the stripes and produce an exactly inverted image, which is the most
|
||||
/// likely way for this to be subtly wrong and the one an assertion about
|
||||
/// "contrast" or "variance" would sail straight past.
|
||||
const WINDOW: (u32, u32) = (301, 157);
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
// CI runners and headless machines may have no usable adapter. Skip rather
|
||||
// than fail, exactly as the rest of this crate's device tests do.
|
||||
match pollster::block_on(GpuContext::new_headless()) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
eprintln!("skipping: no GPU adapter ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Black and white alternating every column: the finest detail an image can
|
||||
/// carry.
|
||||
fn stripes(ctx: &GpuContext) -> DemosaicedImage {
|
||||
let data: Vec<u8> = (0..SOURCE * SOURCE)
|
||||
.flat_map(|i| {
|
||||
let v = if (i % SOURCE).is_multiple_of(2) { 0u8 } else { 255 };
|
||||
[v, v, v, 255]
|
||||
})
|
||||
.collect();
|
||||
DemosaicedImage::from_rgba8(ctx, &data, SOURCE, SOURCE).expect("upload")
|
||||
}
|
||||
|
||||
/// What the source holds at `(x, y)` — the same rule [`stripes`] wrote.
|
||||
fn source_byte(x: u32, _y: u32) -> u8 {
|
||||
if x.is_multiple_of(2) {
|
||||
0
|
||||
} else {
|
||||
255
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a neutral edit through `view` and hand back the bytes and the size.
|
||||
///
|
||||
/// Neutral because this is a test about *which pixel* is read, and any active
|
||||
/// operation would put a colour transform between the source byte and the
|
||||
/// rendered one for no gain.
|
||||
fn render(ctx: &GpuContext, source: &DemosaicedImage, view: CropRect) -> (Vec<u8>, u32, u32) {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.framing_mut().set_view(view);
|
||||
let shader = graph.compose();
|
||||
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
.render(source, &shader, RENDER, RENDER)
|
||||
.expect("render");
|
||||
adjust.export_pixels().expect("readback")
|
||||
}
|
||||
|
||||
/// The view rect that puts one render pixel on one source pixel, with its
|
||||
/// top-left corner at `WINDOW`.
|
||||
fn one_to_one() -> CropRect {
|
||||
CropRect {
|
||||
x: WINDOW.0 as f32 / SOURCE as f32,
|
||||
y: WINDOW.1 as f32 / SOURCE as f32,
|
||||
width: RENDER as f32 / SOURCE as f32,
|
||||
height: RENDER as f32 / SOURCE as f32,
|
||||
}
|
||||
}
|
||||
|
||||
/// The red channel of one row of a rendered frame.
|
||||
fn row(pixels: &[u8], width: u32, y: u32) -> Vec<u8> {
|
||||
(0..width)
|
||||
.map(|x| pixels[((y * width + x) * 4) as usize])
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-5
|
||||
/// The proxy render carries none of the source's finest detail.
|
||||
///
|
||||
/// Half of the argument, and the half that makes the other half mean something:
|
||||
/// if the fit render already showed the stripes, a 1:1 render showing them would
|
||||
/// prove nothing at all. It comes out uniform, so whatever the 1:1 render
|
||||
/// contains cannot have come from resampling it.
|
||||
#[test]
|
||||
fn a_proxy_cannot_resolve_the_finest_detail_in_the_source() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = stripes(&ctx);
|
||||
|
||||
let (pixels, w, h) = render(&ctx, &source, CropRect::default());
|
||||
assert_eq!((w, h), (RENDER, RENDER));
|
||||
|
||||
let first = pixels[0];
|
||||
let uniform = pixels
|
||||
.chunks_exact(4)
|
||||
.all(|px| px[0] == first && px[1] == first && px[2] == first);
|
||||
assert!(
|
||||
uniform,
|
||||
"the fit render of a one-pixel stripe pattern should be flat — a 1024 px \
|
||||
source in a {RENDER} px viewport steps 8 source columns per output \
|
||||
column, so every sample lands on the same phase. It was not: row 0 is \
|
||||
{:?}. Either the sampling changed or the fixture no longer says what it \
|
||||
is meant to, and the 1:1 test below is worthless until this is true \
|
||||
again.",
|
||||
&row(&pixels, w, 0)[..16.min(w as usize)]
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-5
|
||||
/// At 1:1 the render *is* the source region, byte for byte.
|
||||
///
|
||||
/// The requirement's actual content — "at 1:1 and above, the pipeline operates
|
||||
/// on the visible crop at full source resolution" — stated as the strongest
|
||||
/// thing that could be true of it: not that the result is sharper, but that
|
||||
/// output pixel `(x, y)` is source pixel `(WINDOW.0 + x, WINDOW.1 + y)` and
|
||||
/// nothing has been interpolated, averaged or magnified on the way.
|
||||
#[test]
|
||||
fn a_one_to_one_view_reproduces_the_source_pixel_for_pixel() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = stripes(&ctx);
|
||||
|
||||
let (pixels, w, h) = render(&ctx, &source, one_to_one());
|
||||
|
||||
// The render target keeps its size while the sampled region shrinks. That
|
||||
// is the whole mechanism, and a zoom that resized the target would be
|
||||
// magnification rather than resolution.
|
||||
assert_eq!(
|
||||
(w, h),
|
||||
(RENDER, RENDER),
|
||||
"zooming must not change the size of the render target"
|
||||
);
|
||||
|
||||
let mut mismatches = Vec::new();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let got = pixels[((y * w + x) * 4) as usize];
|
||||
let want = source_byte(WINDOW.0 + x, WINDOW.1 + y);
|
||||
if got != want && mismatches.len() < 8 {
|
||||
mismatches.push((x, y, got, want));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
mismatches.is_empty(),
|
||||
"a 1:1 view starting at {WINDOW:?} must reproduce the source exactly. \
|
||||
First mismatches (x, y, got, want): {mismatches:?}\n\
|
||||
rendered row 0: {:?}\n\
|
||||
source row 0: {:?}\n\
|
||||
An exactly inverted row means the view's *offset* was dropped while its \
|
||||
width was honoured; a flat row means the view was ignored altogether \
|
||||
and the pipeline is still rendering the proxy.",
|
||||
&row(&pixels, w, 0)[..12],
|
||||
(0..12)
|
||||
.map(|x| source_byte(WINDOW.0 + x, WINDOW.1))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-5
|
||||
/// An arbitrary zoom between fit and 1:1 samples at the ratio it asks for.
|
||||
///
|
||||
/// FR-DSP-5 says "fit, 1:1, **and arbitrary zoom levels**", and the two tests
|
||||
/// above only pin the ends. This one takes the middle: a view a quarter of the
|
||||
/// frame wide, which puts four source pixels behind each output pixel, and
|
||||
/// checks that the pipeline reports and samples at that ratio rather than
|
||||
/// snapping to one of the two cases anybody would have special-cased.
|
||||
///
|
||||
/// `render_scale` is asserted alongside the pixels because it is what the
|
||||
/// neighbourhood stage converts kernel radii through: a zoom that moved the
|
||||
/// pixels but not the scale would silently sharpen at the wrong radius, which
|
||||
/// is invisible until somebody compares a preview against an export.
|
||||
#[test]
|
||||
fn an_arbitrary_zoom_samples_at_the_ratio_it_asks_for() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = stripes(&ctx);
|
||||
|
||||
// A quarter of the frame: 256 source columns across 128 output columns.
|
||||
let view = CropRect {
|
||||
x: 0.25,
|
||||
y: 0.25,
|
||||
width: 0.25,
|
||||
height: 0.25,
|
||||
};
|
||||
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.framing_mut().set_view(view);
|
||||
|
||||
// The region on screen is 256 source pixels wide, rendered into 128, so the
|
||||
// ratio is one render pixel per two source pixels.
|
||||
let scale = graph.render_scale((SOURCE, SOURCE), (RENDER, RENDER));
|
||||
assert_eq!(scale.full_size(), (256, 256));
|
||||
assert!(
|
||||
(scale.ratio() - 0.5).abs() < 1e-6,
|
||||
"ratio {}",
|
||||
scale.ratio()
|
||||
);
|
||||
|
||||
let (pixels, w, _) = render(&ctx, &source, view);
|
||||
|
||||
// Where output column `x` reads from, worked through rather than asserted
|
||||
// from a previous run — a test that recomputed this with the shader's own
|
||||
// expression would agree with a bug in it.
|
||||
//
|
||||
// uv = 0.25 + (x + 0.5) / 128 * 0.25 = (128 + x + 0.5) / 512
|
||||
// col = floor(uv * 1024) = floor(256 + 2x + 1.0) = 257 + 2x
|
||||
//
|
||||
// Odd for every `x`, so this zoom lands flat — as the fit render does, and
|
||||
// for the same reason. **The 1.0 is the interesting part.** At a two-to-one
|
||||
// downsample an output pixel's centre falls exactly on the boundary between
|
||||
// the two source pixels it covers, and truncation takes the right-hand one.
|
||||
// That is nearest-neighbour behaving correctly and not an off-by-one; a
|
||||
// reader checking this file by hand will get 256 on the first attempt, so
|
||||
// it is written out.
|
||||
const SAMPLED: u32 = 257;
|
||||
|
||||
let first = pixels[0];
|
||||
assert!(
|
||||
pixels.chunks_exact(4).all(|px| px[0] == first),
|
||||
"a two-to-one zoom steps two source columns per output column, so every \
|
||||
sample has the same parity and the frame should be flat. row 0: {:?}",
|
||||
&row(&pixels, w, 0)[..12]
|
||||
);
|
||||
// And it is flat on the *right* phase. A pipeline that had ignored the view
|
||||
// entirely would also be flat — but its `ratio` would not be 0.5, which the
|
||||
// assertion above already rules out — and one that had snapped to 1:1 would
|
||||
// show the stripes instead. Between them, only sampling the window the view
|
||||
// actually asked for produces this.
|
||||
assert_eq!(
|
||||
first,
|
||||
source_byte(SAMPLED, SAMPLED),
|
||||
"a view starting a quarter of the way across a {SOURCE} px source should \
|
||||
sample from source column {SAMPLED}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user