use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::time::Instant;
use darkly::brush::builtin_brushes;
use darkly::engine::DarklyEngine;
use darkly::format::stroke_recording::{replay, EventTiming, ReplayPacing, StrokeRecording};
use darkly::gpu::context::GpuContext;
use darkly::gpu::test_utils::test_device;
const DAB_REFERENCE_SIZE_PX: f32 = 512.0;
#[derive(Debug)]
struct Args {
input: PathBuf,
brush: String,
dab_size_px: Option<f32>,
canvas: Option<(u32, u32)>,
output: Option<PathBuf>,
}
fn parse_args() -> Args {
let mut args = Args {
input: PathBuf::new(),
brush: "round".to_string(),
dab_size_px: None,
canvas: None,
output: None,
};
let mut input_set = false;
let mut argv = std::env::args().skip(1);
while let Some(a) = argv.next() {
match a.as_str() {
"--input" | "-i" => {
args.input = PathBuf::from(argv.next().expect("--input requires a path"));
input_set = true;
}
"--brush" | "-b" => {
args.brush = argv.next().expect("--brush requires a name");
}
"--dab-size" | "-d" => {
let v: f32 = argv
.next()
.expect("--dab-size requires a value")
.parse()
.expect("--dab-size must be a number");
args.dab_size_px = Some(v);
}
"--canvas" | "-c" => {
let v = argv.next().expect("--canvas requires WxH");
args.canvas = Some(parse_canvas(&v));
}
"--output" | "-o" => {
args.output = Some(PathBuf::from(
argv.next().expect("--output requires a path"),
));
}
"-h" | "--help" => {
print_help();
std::process::exit(0);
}
other => panic!("unknown arg: {other}"),
}
}
if !input_set {
eprintln!("error: --input <path> is required");
print_help();
std::process::exit(2);
}
args
}
fn parse_canvas(s: &str) -> (u32, u32) {
let (w, h) = s
.split_once(['x', 'X'])
.unwrap_or_else(|| panic!("--canvas expects WxH (got `{s}`)"));
(
w.parse().expect("canvas width must be a u32"),
h.parse().expect("canvas height must be a u32"),
)
}
fn print_help() {
eprintln!(
"stroke_replay_bench --input <path> [--brush <name>] [--dab-size <px>] \
[--canvas <WxH>] [--output <tsv>]"
);
}
fn brush_graph_json(brush_name: &str, dab_size_px: Option<f32>) -> String {
let mut brush = builtin_brushes::all()
.into_iter()
.find(|b| b.metadata.name.eq_ignore_ascii_case(brush_name))
.unwrap_or_else(|| {
eprintln!(
"error: brush `{brush_name}` not found. Available: {}",
builtin_brushes::all()
.iter()
.map(|b| b.metadata.name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
std::process::exit(2);
});
if let Some(radius_px) = dab_size_px {
let size_port_value = (2.0 * radius_px) / DAB_REFERENCE_SIZE_PX;
let terminal_id = darkly::brush::find_terminal(&brush.metadata.graph)
.unwrap_or_else(|err| panic!("brush `{brush_name}`: {err}"));
brush
.metadata
.graph
.set_port_default(terminal_id, "size", size_port_value)
.expect("set size port default");
}
serde_json::to_string(&brush.metadata.graph).expect("serialize brush graph")
}
fn build_engine(canvas: (u32, u32)) -> DarklyEngine {
let (device, queue) = test_device();
let gpu = GpuContext::new_headless(device, queue);
DarklyEngine::new(gpu, canvas.0, canvas.1)
}
fn percentile(sorted: &[u64], pct: f64) -> f64 {
if sorted.is_empty() {
return 0.0;
}
let idx = ((sorted.len() as f64) * pct).clamp(0.0, sorted.len() as f64 - 1.0);
let lo = idx.floor() as usize;
let hi = idx.ceil() as usize;
if lo == hi {
return sorted[lo] as f64;
}
let frac = idx - lo as f64;
sorted[lo] as f64 * (1.0 - frac) + sorted[hi] as f64 * frac
}
fn git_sha() -> String {
std::process::Command::new("git")
.args(["rev-parse", "--short=10", "HEAD"])
.output()
.ok()
.filter(|out| out.status.success())
.and_then(|out| String::from_utf8(out.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
fn workspace_root() -> PathBuf {
let manifest = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest)
.parent()
.and_then(|p| p.parent())
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from(manifest))
}
fn default_output_path(input: &Path) -> PathBuf {
let stem = input
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "recording".to_string());
let sha = git_sha();
workspace_root()
.join("crates/darkly/bench-results")
.join(format!("stroke-replay-{stem}-{sha}.tsv"))
}
fn write_tsv(path: &Path, timings: &[EventTiming]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = fs::File::create(path)?;
writeln!(
file,
"ev_index\tt_offset_ms\tcpu_us\t\
submit_us\tsubmits\tdab_flushes\tdabs_total\tunion_bbox_area_total"
)?;
for t in timings {
writeln!(
file,
"{}\t{:.3}\t{}\t{}\t{}\t{}\t{}\t{}",
t.index,
t.t_offset_ms,
t.cpu_us,
t.submit_us,
t.submits,
t.dab_flushes,
t.dabs_total,
t.union_bbox_area_total,
)?;
}
Ok(())
}
fn main() {
let args = parse_args();
let recording = StrokeRecording::load(&args.input).unwrap_or_else(|e| {
eprintln!("failed to load recording {}: {e}", args.input.display());
std::process::exit(1);
});
let target_canvas = args
.canvas
.unwrap_or((recording.canvas_width, recording.canvas_height));
let graph_json = brush_graph_json(&args.brush, args.dab_size_px);
let mut engine = build_engine(target_canvas);
engine
.set_brush_graph(&graph_json)
.expect("brush graph compiles");
let layer_id = engine.add_raster_layer(None);
eprintln!(
"replaying {} ({} events) -> brush={} canvas={}x{} dab_size={:?}",
args.input.display(),
recording.events.len(),
args.brush,
target_canvas.0,
target_canvas.1,
args.dab_size_px,
);
let wall_start = Instant::now();
let timings = replay(
&mut engine,
&recording,
layer_id,
target_canvas,
ReplayPacing::Realtime,
);
let wall_elapsed_ms = wall_start.elapsed().as_secs_f64() * 1000.0;
let mut cpu_us: Vec<u64> = timings.iter().map(|t| t.cpu_us).collect();
cpu_us.sort_unstable();
let total_cpu_us: u64 = cpu_us.iter().sum();
let median_us = percentile(&cpu_us, 0.5);
let p95_us = percentile(&cpu_us, 0.95);
let mut submit_us_sorted: Vec<u64> = timings.iter().map(|t| t.submit_us).collect();
submit_us_sorted.sort_unstable();
eprintln!(
"replayed {} events in {:.1} ms wall (cpu_total={} µs, cpu_median={:.0} µs, \
cpu_p95={:.0} µs, submit_p50 = {:.0} µs)",
timings.len(),
wall_elapsed_ms,
total_cpu_us,
median_us,
p95_us,
percentile(&submit_us_sorted, 0.5),
);
let output = args
.output
.unwrap_or_else(|| default_output_path(&args.input));
match write_tsv(&output, &timings) {
Ok(_) => eprintln!("wrote {}", output.display()),
Err(e) => eprintln!("failed to write {}: {e}", output.display()),
}
}