Files
designcraft/apps/designcraft-cli/src/perf.rs
T
NetterSilverandClaude Opus 5.5 1db1635a88 Ask the composition cache once per story and page in PDF export
The PDF exporter called Cache::get twice for every text frame it drew.
Each call rebuilds the cache signature of the whole story, which looks up
every frame of the story with Document::find (a linear walk over all
spreads), and the anchored-frame placements walk them again. For a story
threaded through many frames this is O(frames^2 x items) per export.

The document doesn't change during an export, so the exporter now keeps
the composition it got for each (story, page) and reuses it. The cache
still decides what to compose; the output is byte-identical.

Add examples/pdf_export.rs to designcraft-cli: File > Export PDF through
a headless session on the `designcraft-cli perf` stress document (200
pages, 1M characters in 40 threaded stories, 500 placed images). The
stress document generator moves unchanged from src/perf.rs to a small
library module (src/stress.rs) that the binary and the example share.

Ryzen 16 threads, Linux, 12 runs each, interleaved:
  main     median 3485 ms, min 3427 ms
  patched  median 2016 ms, min 1656 ms  (-42 % median, -52 % min)
The exported PDF is byte-identical (12,075,744 bytes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:47:25 +01:00

373 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `designcraft-cli perf` / `bench`: the performance budgets (plan/architecture.md §9) measured on
//! a synthetic stress document, or on a given file.
//!
//! Each row is the median of several runs. Timings are wall-clock: on a busy machine (load
//! average above ~¾ of the core count) they are noise, and the report says so.
//!
//! Zoom levels follow the canvas: 100% on a 2880×1800 HiDPI canvas is 2 device pixels per point.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
use designcraft_compose::Cache;
use designcraft_doc::{Document, SpreadRef, StoryId};
use designcraft_geom::{Affine, Rect, Vec2};
use designcraft_render::{Placed, RenderOptions, Renderer};
use designcraft_cli::stress::{Spec, synthetic};
/// Device pixels per point at 100% (HiDPI canvas).
const HIDPI: f64 = 2.0;
const W: u32 = 2880;
const H: u32 = 1800;
fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 {
let mut v: Vec<f64> = (0..runs.max(1))
.map(|_| {
let t = Instant::now();
f();
t.elapsed().as_secs_f64() * 1000.0
})
.collect();
v.sort_by(f64::total_cmp);
v[v.len() / 2]
}
/// 1-minute load average (macOS / Linux), if available.
fn load_average() -> Option<f64> {
let out = std::process::Command::new("sysctl").args(["-n", "vm.loadavg"]).output().ok().filter(|o| o.status.success());
let text = match out {
Some(o) => String::from_utf8_lossy(&o.stdout).to_string(),
None => std::fs::read_to_string("/proc/loadavg").ok()?,
};
text.split_whitespace().map(|t| t.trim_matches(|c| c == '{' || c == '}')).find_map(|t| t.parse().ok())
}
/// Compose every story into a fresh cache on `threads` threads.
fn compose_all(doc: &Document, threads: usize) -> Arc<Cache> {
let cache = Arc::new(Cache::new());
let ids: Vec<StoryId> = doc.stories.keys().copied().collect();
let next = AtomicUsize::new(0);
std::thread::scope(|s| {
for _ in 0..threads.max(1) {
s.spawn(|| {
loop {
let i = next.fetch_add(1, Ordering::Relaxed);
let Some(id) = ids.get(i) else { break };
drop(cache.get(doc, *id, None));
}
});
}
});
cache
}
/// All spreads stacked vertically, centred on x = 0 (like the canvas).
fn layout(doc: &Document) -> Vec<(Placed, Rect)> {
let mut y = 0.0;
doc.spreads
.iter()
.enumerate()
.map(|(i, sp)| {
let b = sp.bounds();
let offset = Vec2::new(-b.center().x, y - b.y0);
y += b.height() + 36.0;
(Placed { spread: SpreadRef::Doc(i), xf: designcraft_geom::Affine::translate(offset) }, b + offset)
})
.collect()
}
/// View transform showing canvas point `c` at the centre of the image at `scale` px/pt.
fn view_at(c: designcraft_geom::Point, scale: f64) -> Affine {
Affine::translate((W as f64 / 2.0, H as f64 / 2.0)) * Affine::scale(scale) * Affine::translate(-c.to_vec2())
}
struct Row {
name: String,
ms: f64,
budget: Option<f64>,
}
fn row(rows: &mut Vec<Row>, name: impl Into<String>, ms: f64, budget: Option<f64>) {
let name = name.into();
eprintln!(" … {name}: {ms:.2} ms");
rows.push(Row { name, ms, budget });
}
/// Measure `doc`. `runs` = repetitions per row (median).
fn measure(doc: &Document, runs: usize) -> Result<Vec<Row>, String> {
let mut rows = vec![];
let cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
let chars: usize = doc.stories.values().map(|s| s.text.len()).sum();
// Budget: 250 ms per 300k characters (MT), scaled to this document.
let compose_budget = 250.0 * (chars as f64 / 300_000.0).max(1.0);
let mut cache = Arc::new(Cache::new());
row(
&mut rows,
format!("compose all stories cold, MT ({} stories, {}k chars)", doc.stories.len(), chars / 1000),
median_ms(runs.min(3), || cache = compose_all(doc, cores)),
Some(compose_budget),
);
row(&mut rows, "compose all stories cold, 1 thread", median_ms(1, || drop(compose_all(doc, 1))), None);
let placed = layout(doc);
let all: Vec<Placed> = placed.iter().map(|p| p.0).collect();
let mid = placed.len() / 2;
let centre = placed[mid].1.center();
let greek = |scale: f64| RenderOptions {
background: Some([230, 230, 230, 255]),
page_shadow: true,
greek_below_px: 3.0 * HIDPI.min(scale.max(1.0)),
..Default::default()
};
let mut r = Renderer::new();
// Warm-up: decode images, glyph outlines, compose (already cached).
let fit = {
let b = placed[mid].1;
let s = ((W as f64 - 80.0) / b.width()).min((H as f64 - 80.0) / b.height());
(s, view_at(centre, s))
};
let dump = std::env::var("DESIGNCRAFT_PERF_DUMP").ok();
let save = |name: &str, img: designcraft_render::Rendered| {
if let Some(d) = &dump {
let _ = std::fs::create_dir_all(d);
let _ = std::fs::write(format!("{d}/{name}.png"), img.to_png());
}
};
save("fit", r.render(doc, &cache, &all, W, H, fit.1, &greek(fit.0)));
row(
&mut rows,
format!("render spread, fit ({:.0}%), {W}×{H}", fit.0 / HIDPI * 100.0),
median_ms(runs, || drop(r.render(doc, &cache, &all, W, H, fit.1, &greek(fit.0)))),
Some(16.0),
);
for zoom in [0.25, 1.0, 4.0] {
let s = zoom * HIDPI;
// At 100% look at the text block of the left page.
let c = if zoom >= 1.0 { centre - Vec2::new(placed[mid].1.width() / 4.0, 100.0) } else { centre };
let v = view_at(c, s);
save(&format!("zoom-{:.0}", zoom * 100.0), r.render(doc, &cache, &all, W, H, v, &greek(s)));
let budget = if zoom == 1.0 { Some(16.0) } else { None };
row(
&mut rows,
format!("render at {:.0}%, {W}×{H}", zoom * 100.0),
median_ms(runs, || drop(r.render(doc, &cache, &all, W, H, v, &greek(s)))),
budget,
);
}
// Panning at 100%: a new view every frame (no pixel cache), same glyph caches.
let mut dy = 0.0;
let pan = median_ms(runs, || {
dy += 37.0;
let v = view_at(centre + Vec2::new(-200.0, dy - 300.0), HIDPI);
drop(r.render(doc, &cache, &all, W, H, v, &greek(HIDPI)));
});
row(&mut rows, "render at 100% while panning", pan, Some(16.0));
// Cold renderer (no glyph-path / image caches): first frame after open.
let cold = median_ms(runs.min(3), || {
let mut r = Renderer::new();
drop(r.render(doc, &cache, &all, W, H, view_at(centre, HIDPI), &greek(HIDPI)));
});
row(&mut rows, "render at 100%, cold renderer", cold, None);
// Keystroke: insert a character in the story of the spread's first frame, recompose, repaint.
if let Some(sid) = doc.spreads[mid].items.iter().find_map(|i| i.text_frame().map(|t| t.story)) {
let mut d = doc.clone();
let mut k = 0usize;
let v = view_at(centre - Vec2::new(placed[mid].1.width() / 4.0, 100.0), HIDPI);
let ms = median_ms(runs, || {
if let Some(st) = d.stories.get_mut(&sid) {
let st = Arc::make_mut(st);
let at = (st.text.len() / 2 + k).min(st.text.len());
let at = (0..=at).rev().find(|i| st.text.is_char_boundary(*i)).unwrap_or(0);
st.text.insert(at, 'x');
if let Some(run) = st.chars.iter_mut().find(|r| r.len > 0) {
run.len += 1;
}
st.rev += 1;
k += 1;
}
drop(r.render(&d, &cache, &all, W, H, v, &greek(HIDPI)));
});
row(&mut rows, "keystroke: recompose story + full repaint at 100%", ms, None);
// What the canvas does: repaint only the damaged region (designcraft_render::damage).
let ms = median_ms(runs, || {
let old = d.clone();
if let Some(st) = d.stories.get_mut(&sid) {
let st = Arc::make_mut(st);
let at = (st.text.len() / 2 + k).min(st.text.len());
let at = (0..=at).rev().find(|i| st.text.is_char_boundary(*i)).unwrap_or(0);
st.text.insert(at, 'x');
if let Some(run) = st.chars.iter_mut().find(|r| r.len > 0) {
run.len += 1;
}
st.rev += 1;
k += 1;
}
let screen = designcraft_geom::Rect::new(0.0, 0.0, W as f64, H as f64);
let mut px: Option<designcraft_geom::Rect> = None;
for (sr, rect) in designcraft_render::damage::damage_with(&old, &d, Some(&cache)).unwrap_or_default() {
let m = all.iter().find(|p| p.spread == sr).map_or(designcraft_geom::Affine::IDENTITY, |p| p.xf);
let r = v.transform_rect_bbox(m.transform_rect_bbox(rect)).intersect(screen);
if r.width() > 0.0 && r.height() > 0.0 {
px = Some(px.map_or(r, |p| p.union(r)));
}
}
if let Some(p) = px {
let (x0, y0) = (p.x0.floor(), p.y0.floor());
let (w, h) = ((p.x1.ceil() - x0) as u32, (p.y1.ceil() - y0) as u32);
drop(r.render(&d, &cache, &all, w.max(1), h.max(1), Affine::translate((-x0, -y0)) * v, &greek(HIDPI)));
}
});
row(&mut rows, "keystroke: recompose story + damage repaint at 100%", ms, Some(8.0));
}
let opts = RenderOptions { printing_only: true, ..Default::default() };
let mut png = 0usize;
let mut failed: Option<String> = None;
let ms = median_ms(runs.min(5), || match r.render_page(doc, &cache, doc.first_page_of_spread(mid), 150.0 / 72.0, true, &opts) {
Some(img) => png = img.to_png().len(),
None => failed = Some("the middle spread has no page to render".into()),
});
row(&mut rows, format!("export page PNG at 150 dpi ({} kB)", png / 1024), ms, None);
let mut bytes = vec![];
row(
&mut rows,
format!("save .designcraft ({} pages)", doc.page_count()),
median_ms(runs.min(3), || match designcraft_format::save(doc) {
Ok(b) => bytes = b,
Err(e) => failed = Some(format!("save: {e}")),
}),
Some(300.0),
);
let open_ms = median_ms(runs.min(3), || {
if let Err(e) = designcraft_format::load(&bytes) {
failed = Some(format!("open: {e}"));
}
});
row(&mut rows, "open .designcraft", open_ms, Some(300.0));
let n = doc.page_count().min(100);
let pdf = designcraft_pdf::PdfOptions { pages: Some((0..n).collect()), ..Default::default() };
row(
&mut rows,
format!("PDF export {n} pages"),
median_ms(1, || {
if let Err(e) = designcraft_pdf::export_pdf(doc, &cache, &pdf) {
failed = Some(format!("PDF export: {e}"));
}
}),
Some(2000.0 * n as f64 / 100.0),
);
match failed {
Some(e) => Err(e),
None => Ok(rows),
}
}
fn print(title: &str, rows: &[Row], noisy: bool) -> Result<(), String> {
outln!("{title}");
let mut over = 0;
outln!(" {:<60} {:>11} {:>10}", "", "measured", "budget");
for r in rows {
let (b, flag) = match r.budget {
Some(b) if r.ms > b => {
over += 1;
(format!("{b:>7.0} ms"), "OVER")
}
Some(b) => (format!("{b:>7.0} ms"), "ok"),
None => (" — ".into(), ""),
};
outln!(" {:<60} {:>8.2} ms {b} {flag}", r.name, r.ms);
}
match (over, noisy) {
(0, _) => Ok(()),
(_, true) => {
outln!("{over} over budget, but the machine is busy: re-run when idle.");
Ok(())
}
_ => {
outln!("{over} budget(s) exceeded");
Ok(())
}
}
}
fn header() -> (usize, bool) {
let cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
let load = load_average();
let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
outln!(
"{cores} cores, load average {}, render threads {}",
load.map_or("?".into(), |l| format!("{l:.1}")),
designcraft_render::default_threads()
);
if noisy {
outln!("WARNING: the machine is busy (load average above ¾ of the cores); wall-clock timings below are not trustworthy.");
}
(cores, noisy)
}
/// `designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict]`.
pub fn perf(args: &[String]) -> Result<(), String> {
let mut spec = Spec::default();
let mut runs = 7usize;
let mut strict = false;
let mut it = args.iter();
while let Some(a) = it.next() {
let mut num = || it.next().and_then(|v| v.parse::<usize>().ok()).ok_or(format!("{a} needs a number"));
match a.as_str() {
"--pages" => spec.pages = num()?.max(1),
"--frames" => spec.frames_per_page = num()?.max(1),
"--chars" => spec.chars = num()?,
"--images" => spec.images = num()?,
"--runs" => runs = num()?.max(1),
"--strict" => strict = true,
other => return Err(format!("unknown perf option `{other}`")),
}
}
let (_, noisy) = header();
let t = Instant::now();
let doc = synthetic(&spec)?;
let frames = spec.pages * spec.frames_per_page;
outln!(
"DesignCraft performance budgets — synthetic: {} pages, {frames} text frames in {} threaded stories, {}k chars, {} images ({} assets), {} gradients (built in {:.0} ms)",
spec.pages,
doc.stories.len(),
doc.stories.values().map(|s| s.text.len()).sum::<usize>() / 1000,
spec.images,
spec.assets,
spec.pages,
t.elapsed().as_secs_f64() * 1000.0
);
let rows = measure(&doc, runs)?;
print("", &rows, noisy)?;
let over = rows.iter().filter(|r| r.budget.is_some_and(|b| r.ms > b)).count();
if strict && over > 0 && !noisy { Err(format!("{over} budget(s) exceeded")) } else { Ok(()) }
}
/// `designcraft-cli bench FILE [--runs N]`: the same measurements on one document.
pub fn bench(args: &[String]) -> Result<(), String> {
let mut file = None;
let mut runs = 5usize;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--runs" => runs = it.next().and_then(|v| v.parse().ok()).ok_or("--runs needs a number")?,
f if file.is_none() && !f.starts_with("--") => file = Some(f.to_string()),
other => return Err(format!("unknown bench option `{other}`")),
}
}
let file = file.ok_or("usage: designcraft-cli bench FILE [--runs N]")?;
let (_, noisy) = header();
let mut s = designcraft_engine::Session::new();
let t = Instant::now();
s.execute("file.open", &serde_json::json!({"path": file})).map_err(|e| e.to_string())?;
let open = t.elapsed().as_secs_f64() * 1000.0;
let doc = s.doc().map_err(|e| e.to_string())?.doc.clone();
outln!("{file}: {} pages, {} stories, {} assets (opened in {open:.0} ms)", doc.page_count(), doc.stories.len(), doc.assets.len());
let rows = measure(&doc, runs)?;
print("", &rows, noisy)
}