mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
M5.4: drawcraft-cli perf — plan §4 budgets on a synthetic 50k-path document, with a busy-machine warning
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
318a55350a
commit
b318a62edd
5 files changed
+166
-2
No files matched your search
@@ -31,7 +31,7 @@ DrawCraft is a clean-room, open-source, Rust-native vector illustration app targ
|
||||
- `cargo run --release -p drawcraft -- --control 7979 [file.svg|file.drawcraft]`
|
||||
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`.
|
||||
- **For UI work, look at the result** (take `ui.screenshot`, read the PNG) and compare with `plan/illustrator/02-ui-ux.md` / `10-observed-ui.md`. `ui.screenshot` needs a presented frame: if it errors (screen locked), check the art with `ui.render` / `drawcraft-cli run … --export x.png`, and cover panels with a headless egui frame test (see `panels/transparency.rs` tests).
|
||||
- **Performance:** `drawcraft-cli bench FILE [--size WxH] [--iters N]` prints ms/frame (multi- and single-threaded). Check it before and after renderer changes.
|
||||
- **Performance:** `drawcraft-cli perf` checks the budgets (render/pan/zoom, hit test, save/open, SVG, Pathfinder) on a synthetic 50k-path document; `drawcraft-cli bench FILE` times one file. Run them before and after renderer, format or geometry changes, on an idle machine (the report warns when the load average makes timings noise).
|
||||
- MCP: `drawcraft-cli mcp` (see `docs/mcp.md`).
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`).
|
||||
|
||||
@@ -31,7 +31,7 @@ DrawCraft is a clean-room, open-source, Rust-native vector illustration app targ
|
||||
- `cargo run --release -p drawcraft -- --control 7979 [file.svg|file.drawcraft]`
|
||||
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`.
|
||||
- **For UI work, look at the result** (take `ui.screenshot`, read the PNG) and compare with `plan/illustrator/02-ui-ux.md` / `10-observed-ui.md`. `ui.screenshot` needs a presented frame: if it errors (screen locked), check the art with `ui.render` / `drawcraft-cli run … --export x.png`, and cover panels with a headless egui frame test (see `panels/transparency.rs` tests).
|
||||
- **Performance:** `drawcraft-cli bench FILE [--size WxH] [--iters N]` prints ms/frame (multi- and single-threaded). Check it before and after renderer changes.
|
||||
- **Performance:** `drawcraft-cli perf` checks the budgets (render/pan/zoom, hit test, save/open, SVG, Pathfinder) on a synthetic 50k-path document; `drawcraft-cli bench FILE` times one file. Run them before and after renderer, format or geometry changes, on an idle machine (the report warns when the load average makes timings noise).
|
||||
- MCP: `drawcraft-cli mcp` (see `docs/mcp.md`).
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`).
|
||||
|
||||
@@ -14,7 +14,12 @@ path = "src/main.rs"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
drawcraft-color = { workspace = true }
|
||||
drawcraft-doc = { workspace = true }
|
||||
drawcraft-engine = { workspace = true }
|
||||
drawcraft-format = { workspace = true }
|
||||
drawcraft-geom = { workspace = true }
|
||||
drawcraft-svg = { workspace = true }
|
||||
drawcraft-mcp = { workspace = true }
|
||||
drawcraft-render = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
//! drawcraft-cli run [--in file.drawcraft|file.svg] [--cmd id [--params '{json}']]... [--export out.svg|.png|.pdf|.jpg|.webp|.drawcraft]... [--scale 2]
|
||||
//! drawcraft-cli commands
|
||||
//! drawcraft-cli bench FILE [--size 2880x1800] [--iters 5]
|
||||
//! drawcraft-cli perf [--paths 50000]
|
||||
//! ```
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::ExitCode;
|
||||
|
||||
mod perf;
|
||||
|
||||
use drawcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -32,6 +35,10 @@ USAGE:
|
||||
drawcraft-cli bench FILE [--size WxH] [--iters N]
|
||||
Render FILE (.drawcraft/.svg) fitted to WxH (default 2880x1800) and print ms per frame
|
||||
(warm), multithreaded and single-threaded.
|
||||
|
||||
drawcraft-cli perf [--paths N]
|
||||
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder) on a
|
||||
synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded.
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -41,6 +48,7 @@ fn main() -> ExitCode {
|
||||
Some("run") => run(&args[1..]),
|
||||
Some("commands") => commands(),
|
||||
Some("bench") => bench(&args[1..]),
|
||||
Some("perf") => perf::run(&args[1..]),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! `drawcraft-cli perf`: the performance budgets (plan §4) measured on a synthetic document.
|
||||
//!
|
||||
//! Each row is the median of several runs. Timings are wall-clock: on a busy machine (load
|
||||
//! average above the core count) they are noise, and the report says so.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use drawcraft_color::{Color, Paint};
|
||||
use drawcraft_doc::{Appearance, Document, Node, hit};
|
||||
use drawcraft_geom::{Affine, Point, Rect, shapes};
|
||||
use drawcraft_render::{RenderOptions, Renderer};
|
||||
use serde_json::json;
|
||||
|
||||
/// Deterministic xorshift in 0..1.
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> f64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
(self.0 % 1_000_000) as f64 / 1_000_000.0
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` mixed paths (ellipses with strokes, translucent rectangles, stroked stars) on a 1600×1200 page.
|
||||
pub fn synthetic(n: usize) -> Document {
|
||||
let mut d = Document::new(1600.0, 1200.0);
|
||||
let l = d.layers[0].id;
|
||||
let mut r = Rng(42);
|
||||
for i in 0..n {
|
||||
let (x, y, s) = (r.next() * 1600.0, r.next() * 1200.0, 3.0 + r.next() * 22.0);
|
||||
let c = Color::rgb(r.next() as f32, r.next() as f32, r.next() as f32);
|
||||
let id = d.alloc_id();
|
||||
let mut node = match i % 3 {
|
||||
0 => Node::path(
|
||||
id,
|
||||
shapes::ellipse(Rect::from_center_size(Point::new(x, y), (2.0 * s, 2.0 * s))),
|
||||
Appearance::basic(Paint::solid(c), Paint::solid(Color::BLACK), 0.5),
|
||||
),
|
||||
1 => Node::path(id, shapes::rectangle(Rect::new(x, y, x + 2.0 * s, y + s)), Appearance::basic(Paint::solid(c), Paint::None, 0.0)),
|
||||
_ => Node::path(id, shapes::star(Point::new(x, y), s, s / 2.0, 5, 0.0), Appearance::basic(Paint::None, Paint::solid(c), 2.0)),
|
||||
};
|
||||
if i % 3 == 1 {
|
||||
node.opacity = 0.8;
|
||||
}
|
||||
d.insert(Some(l), usize::MAX, node).expect("insert");
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 {
|
||||
let mut v: Vec<f64> = (0..runs)
|
||||
.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())
|
||||
}
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut n = 50_000usize;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--paths" => n = it.next().and_then(|v| v.parse().ok()).ok_or("--paths needs a number")?,
|
||||
other => return Err(format!("unknown perf option `{other}`")),
|
||||
}
|
||||
}
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
let load = load_average();
|
||||
println!("DrawCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
|
||||
let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
|
||||
if noisy {
|
||||
println!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
|
||||
}
|
||||
let t = Instant::now();
|
||||
let doc = synthetic(n);
|
||||
println!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
let mut rows: Vec<(&str, f64, f64)> = vec![];
|
||||
let opts = RenderOptions::default();
|
||||
let mut r = Renderer::new();
|
||||
let fit = Affine::scale(1.6);
|
||||
r.render(&doc, 2880, 1800, fit, &opts);
|
||||
rows.push(("render: fit page, 2880×1800", median_ms(5, || drop(r.render(&doc, 2880, 1800, fit, &opts))), 16.0));
|
||||
let mut dx = 0.0;
|
||||
rows.push((
|
||||
"render: pan frame (view moves 7 px)",
|
||||
median_ms(9, || {
|
||||
dx += 7.0;
|
||||
drop(r.render(&doc, 2880, 1800, Affine::translate((dx, 0.0)) * fit, &opts));
|
||||
}),
|
||||
16.0,
|
||||
));
|
||||
let zoom = Affine::scale(6.4) * Affine::translate((-600.0, -400.0));
|
||||
r.render(&doc, 2880, 1800, zoom, &opts);
|
||||
rows.push(("render: 400% zoom, 2880×1800", median_ms(5, || drop(r.render(&doc, 2880, 1800, zoom, &opts))), 16.0));
|
||||
|
||||
let mut rng = Rng(7);
|
||||
let points: Vec<Point> = (0..200).map(|_| Point::new(rng.next() * 1600.0, rng.next() * 1200.0)).collect();
|
||||
let hits = median_ms(3, || {
|
||||
for p in &points {
|
||||
drop(hit::hit_test(&doc, *p, hit::HitOptions::default()));
|
||||
}
|
||||
}) / points.len() as f64;
|
||||
rows.push(("hit test (per click)", hits, 2.0));
|
||||
|
||||
let mut bytes = vec![];
|
||||
rows.push(("save .drawcraft", median_ms(3, || bytes = drawcraft_format::save_file(&doc)), 300.0));
|
||||
rows.push(("open .drawcraft", median_ms(3, || drop(drawcraft_format::load(&bytes).expect("load"))), 300.0));
|
||||
let svg_opts = drawcraft_svg::ExportOptions::default();
|
||||
rows.push(("export SVG", median_ms(3, || drop(drawcraft_svg::export(&doc, &svg_opts))), 500.0));
|
||||
|
||||
// Pathfinder Unite on 1,000 overlapping paths.
|
||||
let unite = median_ms(3, || {
|
||||
let mut s = drawcraft_engine::Session::new();
|
||||
s.add_document(synthetic(1000), None);
|
||||
s.execute("select.all", &json!({})).expect("select");
|
||||
s.execute("object.pathfinder.unite", &json!({})).expect("unite");
|
||||
});
|
||||
rows.push(("Pathfinder Unite, 1,000 paths", unite, 150.0));
|
||||
|
||||
let mut over = 0;
|
||||
println!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
|
||||
for (name, ms, budget) in &rows {
|
||||
let ok = ms <= budget;
|
||||
over += usize::from(!ok);
|
||||
println!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" });
|
||||
}
|
||||
match (over, noisy) {
|
||||
(0, _) => Ok(()),
|
||||
(_, true) => {
|
||||
println!("{over} over budget, but the machine is busy: re-run when idle.");
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(format!("{over} budget(s) exceeded")),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user