mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-22 19:52:27 -05:00
A batch ran its photos strictly one after another: each render is row-parallel inside, but a photo's decode, its PNG/TIFF/WebP encode and the wait on a GPU render are mostly serial, so a 32-core machine averaged ~9.5 busy cores on the CPU path and 1-2 with the GPU. run_batch now keeps export_parallelism() photos in flight (cores / 4, 1..=4; one on wasm32) on scoped lane threads plus the calling thread. Each holds an estimate of its working memory from a new export_gate (twice the memory budget, separate from the decode gate it nests over), so full-size 24 MP exports (~1.2 GB each) run two at a time by default. Lanes run at most `lanes` photos past the last one placed, which also bounds the finished files waiting for their turn. Paths, conflict handling, original protection, writes, results and progress stay in batch order (Placer), so the files are the same as one at a time; a panic in a lane becomes that photo's error. render_bench's batch scenario now goes through export_batch. 8 x 24 MP JPEGs, 32 cores + RTX 4080 Laptop: CPU 3.87 s -> 2.35 s, GPU 4.21 s -> 1.82 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
295 lines
13 KiB
Rust
295 lines
13 KiB
Rust
//! End-to-end render benchmark (min of N runs, robust to a loaded machine):
|
||
//! `cargo run --release -p lightcraft-engine --example render_bench -- corpus/raw/arw-sony-a7m3-compressed.arw`
|
||
//! (`N=9` runs per scenario, `RAYON_NUM_THREADS=1` for algorithmic comparisons,
|
||
//! `LIGHTCRAFT_PROFILE=1` for per-stage timings).
|
||
//!
|
||
//! Without a file argument a procedural 6000×4000 source is used. Scenarios: a ~2.5 MP loupe render
|
||
//! of the 2560 px preview (cold, and with a warm stage cache while a slider is dragged), a draft,
|
||
//! and a full-size render + JPEG encode (export); `ONLY=batch` with several files times a full-size
|
||
//! export of each (decode + render + encode). Prints minimum wall-clock and minimum process CPU
|
||
//! time: on a shared machine the CPU time shows the work done, wall-clock also the wait for cores.
|
||
//! Each scenario runs on the CPU pipeline and, when a GPU adapter exists, on `lightcraft-gpu`
|
||
//! (second column; `LIGHTCRAFT_GPU=0` for the CPU only).
|
||
use std::sync::Arc;
|
||
use std::time::Instant;
|
||
|
||
use lightcraft_develop::DevelopSettings;
|
||
use lightcraft_engine::export::{ExportOptions, encode_image};
|
||
use lightcraft_engine::media::develop;
|
||
use lightcraft_pipeline::{Quality, RenderRequest, SourceInfo, StageCache};
|
||
use lightcraft_raster::Rgb32f;
|
||
use lightcraft_raster::resample::{Filter, fit};
|
||
|
||
/// Process CPU time in ms (all threads). The only `unsafe` is this libc clock read, in a dev-only example.
|
||
#[allow(unsafe_code)]
|
||
fn cpu_ms() -> f64 {
|
||
#[cfg(unix)]
|
||
{
|
||
let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 };
|
||
// SAFETY: `clock_gettime` only writes into the timespec we pass.
|
||
unsafe { libc::clock_gettime(libc::CLOCK_PROCESS_CPUTIME_ID, &mut ts) };
|
||
ts.tv_sec as f64 * 1e3 + ts.tv_nsec as f64 / 1e6
|
||
}
|
||
#[cfg(not(unix))]
|
||
{
|
||
0.0
|
||
}
|
||
}
|
||
|
||
/// Minimum wall-clock and minimum CPU time (ms) over the runs.
|
||
struct T(f64, f64);
|
||
|
||
impl std::fmt::Display for T {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
write!(f, "{:.1} ms wall, {:.1} ms cpu", self.0, self.1)
|
||
}
|
||
}
|
||
|
||
fn best(n: usize, mut f: impl FnMut()) -> T {
|
||
let mut b = T(f64::MAX, f64::MAX);
|
||
for _ in 0..n {
|
||
let (t, c) = (Instant::now(), cpu_ms());
|
||
f();
|
||
b.1 = b.1.min(cpu_ms() - c);
|
||
b.0 = b.0.min(t.elapsed().as_secs_f64() * 1e3);
|
||
}
|
||
b
|
||
}
|
||
|
||
fn typical() -> DevelopSettings {
|
||
let mut s = DevelopSettings::default();
|
||
s.light.exposure = 0.3;
|
||
s.light.highlights = -40.0;
|
||
s.light.shadows = 30.0;
|
||
s.effects.clarity = 15.0;
|
||
s.effects.texture = 10.0;
|
||
s.effects.dehaze = 10.0;
|
||
s.detail.nr_luminance = 30.0;
|
||
s.detail.nr_color = 25.0;
|
||
s.detail.sharpen_amount = 40.0;
|
||
s
|
||
}
|
||
|
||
/// Max and mean |Δ| (8-bit, RGB) between two renders.
|
||
fn diff(a: &lightcraft_raster::Rgba8, b: &lightcraft_raster::Rgba8) -> (u8, f64) {
|
||
let (mut max, mut sum) = (0u8, 0u64);
|
||
for (p, q) in a.data.iter().zip(&b.data) {
|
||
for c in 0..3 {
|
||
let d = p[c].abs_diff(q[c]);
|
||
max = max.max(d);
|
||
sum += d as u64;
|
||
}
|
||
}
|
||
(max, sum as f64 / (a.data.len() * 3).max(1) as f64)
|
||
}
|
||
|
||
fn main() {
|
||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||
let n: usize = std::env::var("N").ok().and_then(|v| v.parse().ok()).unwrap_or(5);
|
||
let only = std::env::var("ONLY").unwrap_or_default();
|
||
let run = |name: &str| only.is_empty() || only.split(',').any(|o| name.contains(o));
|
||
if only == "source" {
|
||
// opening a photo: where the time goes before the first sharp loupe (per file)
|
||
for path in &args {
|
||
let bytes = std::fs::read(path).expect("read");
|
||
println!("{path}");
|
||
let mut raw = lightcraft_raw::decode(&bytes).expect("decode");
|
||
// as the loader does: lens corrections are applied by the pipeline, not in the raw develop
|
||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||
println!(" {}×{} cpp {} cfa {:?}", raw.width, raw.height, raw.cpp, raw.cfa.as_ref().map(|c| c.name()));
|
||
println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode"))));
|
||
println!(" {:<36} {}", "normalize:", best(n, || drop(raw.normalized().expect("norm"))));
|
||
let norm = raw.normalized().expect("norm");
|
||
println!(" {:<36} {}", "demosaic AHD:", best(n, || drop(lightcraft_raw::demosaic(&norm, lightcraft_raw::Method::Ahd))));
|
||
if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) {
|
||
println!(" {:<36} {}", format!("binned ×{k} (2560 preview):"), best(n, || drop(raw.develop_binned(k, 0.99))));
|
||
let img = raw.develop_binned(k, 0.99).expect("bin").expect("binnable");
|
||
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||
println!(
|
||
" {:<36} {}",
|
||
"highlight reconstruct (binned):",
|
||
best(n, || {
|
||
let mut i = img.clone();
|
||
lightcraft_raw::highlight::reconstruct(&mut i, t.wb, 0.99);
|
||
})
|
||
);
|
||
println!(" {:<36} {}", "clone (binned):", best(n, || drop(img.clone())));
|
||
println!(" {:<36} {}", "fit 2560 (binned):", best(n, || drop(fit(&img, 2560, 2560, Filter::Box))));
|
||
}
|
||
println!(" {:<36} {}", "embedded preview (2560):", best(n, || drop(lightcraft_engine::files::load_embedded_preview(&bytes, 2560))));
|
||
for edge in [512usize, 2560, usize::MAX] {
|
||
println!(
|
||
" {:<36} {}",
|
||
format!("load_bytes({edge}):"),
|
||
best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, edge).expect("load")))
|
||
);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
let (full, info) = match args.first() {
|
||
Some(path) => {
|
||
let bytes = std::fs::read(path).expect("read");
|
||
let t = Instant::now();
|
||
let r = lightcraft_engine::files::load_bytes(&bytes, usize::MAX).expect("decode");
|
||
println!("decode full: {:.0} ms", t.elapsed().as_secs_f64() * 1e3);
|
||
r
|
||
}
|
||
None => (
|
||
Rgb32f::from_fn(6000, 4000, |x, y| {
|
||
let v = ((x as f32 * 0.011).sin() * (y as f32 * 0.007).cos() * 0.5 + 0.5) * 0.4 + ((x * 31 + y * 17) % 13) as f32 * 0.002;
|
||
[v, v * 0.9, v * 0.7]
|
||
}),
|
||
SourceInfo { raw: true, ..Default::default() },
|
||
),
|
||
};
|
||
let full = Arc::new(full);
|
||
println!("source {}×{} ({:.1} MP), {n} runs each", full.width, full.height, (full.width * full.height) as f64 / 1e6);
|
||
// GPU column: `LIGHTCRAFT_GPU=0` (or no adapter) prints only the CPU column.
|
||
let t = Instant::now();
|
||
let gpu = lightcraft_engine::gpu::available();
|
||
match lightcraft_engine::gpu::adapter_name().filter(|_| gpu) {
|
||
Some(a) => println!("gpu: {a} (device + kernels: {:.0} ms)", t.elapsed().as_secs_f64() * 1e3),
|
||
None => println!("gpu: none"),
|
||
}
|
||
let backends: &[bool] = if gpu { &[false, true] } else { &[false] };
|
||
let row = |name: &str, f: &mut dyn FnMut(bool)| {
|
||
let cols: Vec<String> = backends.iter().map(|&g| format!("{}", best(n, || f(g)))).collect();
|
||
println!("{name:<38} cpu: {}{}", cols[0], cols.get(1).map(|g| format!(" | gpu: {g}")).unwrap_or_default());
|
||
};
|
||
let preview = Arc::new(fit(&full, 2560, 2560, Filter::Box));
|
||
let s = typical();
|
||
let view = RenderRequest::fit(1920, 1280);
|
||
let draft = RenderRequest { quality: Quality::Draft, ..RenderRequest::fit(1152, 768) };
|
||
if gpu {
|
||
let c = develop(&preview, &info, &s, &view, None, false).image;
|
||
let g = develop(&preview, &info, &s, &view, None, true).image;
|
||
let (max, mean) = diff(&c, &g);
|
||
println!("gpu vs cpu, loupe 1920×1280 typical: max {max} LSB, mean {mean:.4} LSB");
|
||
}
|
||
|
||
if run("cold") {
|
||
// a new photo: no cached stages (the GPU also uploads the preview)
|
||
row("loupe 1920×1280 cold:", &mut |g| drop(develop(&preview, &info, &s, &view, None, g)));
|
||
row("loupe draft 1152×768 cold:", &mut |g| drop(develop(&preview, &info, &s, &draft, None, g)));
|
||
}
|
||
let caches = [StageCache::default(), StageCache::default()];
|
||
let mut k = 0.0;
|
||
if run("drag") {
|
||
for g in backends {
|
||
drop(develop(&preview, &info, &s, &view, Some(&caches[*g as usize]), *g));
|
||
}
|
||
row("loupe 1920×1280 exposure drag (warm):", &mut |g| {
|
||
let mut t = s.clone();
|
||
k += 1.0;
|
||
t.light.contrast = k % 50.0;
|
||
t.light.exposure = 0.3 + k * 0.01;
|
||
drop(develop(&preview, &info, &t, &view, Some(&caches[g as usize]), g));
|
||
});
|
||
for g in backends {
|
||
drop(develop(&preview, &info, &s, &draft, Some(&caches[*g as usize]), *g));
|
||
}
|
||
row("loupe draft highlights drag (warm):", &mut |g| {
|
||
let mut t = s.clone();
|
||
k += 1.0;
|
||
t.light.highlights = -40.0 + k % 50.0;
|
||
drop(develop(&preview, &info, &t, &draft, Some(&caches[g as usize]), g));
|
||
});
|
||
row("loupe draft clarity drag (warm):", &mut |g| {
|
||
let mut t = s.clone();
|
||
k += 1.0;
|
||
t.effects.clarity = 15.0 + k % 50.0;
|
||
drop(develop(&preview, &info, &t, &draft, Some(&caches[g as usize]), g));
|
||
});
|
||
row("loupe draft NR drag (warm):", &mut |g| {
|
||
let mut t = s.clone();
|
||
k += 1.0;
|
||
t.detail.nr_luminance = 30.0 + k % 50.0;
|
||
drop(develop(&preview, &info, &t, &draft, Some(&caches[g as usize]), g));
|
||
});
|
||
row("loupe 1920×1280 NR drag (warm):", &mut |g| {
|
||
let mut t = s.clone();
|
||
k += 1.0;
|
||
t.detail.nr_luminance = 30.0 + k % 50.0;
|
||
drop(develop(&preview, &info, &t, &view, Some(&caches[g as usize]), g));
|
||
});
|
||
}
|
||
if run("finish") {
|
||
// per-pixel stage cost by feature (warm cache: exposure changes only)
|
||
let variants: [(&str, fn(&mut DevelopSettings)); 6] = [
|
||
("typical", |_| {}),
|
||
("- dehaze", |t| t.effects.dehaze = 0.0),
|
||
("- clarity", |t| t.effects.clarity = 0.0),
|
||
("- texture/sharpen", |t| {
|
||
t.effects.texture = 0.0;
|
||
t.detail.sharpen_amount = 0.0;
|
||
}),
|
||
("- highlights/shadows", |t| {
|
||
t.light.highlights = 0.0;
|
||
t.light.shadows = 0.0;
|
||
}),
|
||
("+ vibrance/saturation", |t| {
|
||
t.color.vibrance = 20.0;
|
||
t.color.saturation = 10.0;
|
||
}),
|
||
];
|
||
for (name, f) in variants {
|
||
let mut t = s.clone();
|
||
f(&mut t);
|
||
for g in backends {
|
||
drop(develop(&preview, &info, &t, &view, Some(&caches[*g as usize]), *g));
|
||
}
|
||
row(&format!("finish 1920×1280 {name}"), &mut |g| {
|
||
k += 1.0;
|
||
t.light.exposure = 0.3 + k * 0.001;
|
||
drop(develop(&preview, &info, &t, &view, Some(&caches[g as usize]), g));
|
||
});
|
||
}
|
||
}
|
||
if run("batch") && args.len() > 1 {
|
||
// full-size JPEG export of all the files given (decode + render + encode), as the Export
|
||
// dialog runs it: several photos side by side (`export_parallelism`), files discarded
|
||
use lightcraft_engine::export::{Destination, export_batch};
|
||
let mut session = lightcraft_engine::Session::new().with_fs();
|
||
let r = session.execute("library.import", &serde_json::json!({"paths": args})).expect("import");
|
||
let ids: Vec<_> = r["imported"].as_array().expect("ids").iter().filter_map(|v| v.as_u64()).map(lightcraft_engine::catalog::PhotoId).collect();
|
||
let o = ExportOptions::default();
|
||
let lanes = lightcraft_engine::export::export_parallelism(ids.len());
|
||
row(&format!("batch export of {} files, {lanes} at a time (decode+render+JPEG):", ids.len()), &mut |g| {
|
||
lightcraft_engine::gpu::set_enabled(g);
|
||
let files = export_batch(&mut session, &ids, &o, &Destination::default(), &mut |_, _| Ok(()), &|_| false).expect("export");
|
||
assert_eq!(files.len(), ids.len());
|
||
for &id in &ids {
|
||
session.media.forget(id); // every original is decoded once per run
|
||
}
|
||
});
|
||
lightcraft_engine::gpu::set_enabled(true);
|
||
return;
|
||
}
|
||
if run("export") {
|
||
let big = RenderRequest::fit(full.width, full.height);
|
||
let ne = n.min(3);
|
||
let mut img = None;
|
||
let cols: Vec<String> = backends
|
||
.iter()
|
||
.map(|&g| {
|
||
let ms = best(ne, || img = Some(develop(&full, &info, &s, &big, None, g).image));
|
||
format!("{ms}")
|
||
})
|
||
.collect();
|
||
println!(
|
||
"{:<38} cpu: {}{}",
|
||
format!("export render {}×{}:", full.width, full.height),
|
||
cols[0],
|
||
cols.get(1).map(|g| format!(" | gpu: {g}")).unwrap_or_default()
|
||
);
|
||
if gpu {
|
||
let c = develop(&full, &info, &s, &big, None, false).image;
|
||
let (max, mean) = diff(&c, img.as_ref().expect("rendered"));
|
||
println!("gpu vs cpu, export {}×{} typical: max {max} LSB, mean {mean:.4} LSB", full.width, full.height);
|
||
}
|
||
let img = img.expect("rendered");
|
||
println!("export JPEG encode: {}", best(ne, || drop(encode_image(&img, &ExportOptions::default()))));
|
||
}
|
||
}
|