Merge pull request #1502 from dotCooCoo/feat/perf-windows-load-memory

CLI: perf on Windows reports the cores busy and its working set, and the help lists --deep
This commit is contained in:
BFlat authored and GitHub committed 2026-10-13 05:09:52 +07:00
commit 15e1f6327f
5 files changed
+97 -9

No files matched your search

Generated
+1
View File
@@ -4580,6 +4580,7 @@ dependencies = [
"vectorcraft-testkit",
"vectorcraft-text",
"windows",
"winsafe",
]
[[package]]
+6
View File
@@ -26,6 +26,12 @@ vectorcraft-text = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
# `perf` on Windows (src/perf.rs) reads how busy the processors are (GetSystemTimes) and this
# process's working set (GetProcessMemoryInfo) through winsafe's safe wrappers over Win32 (MIT),
# which the desktop app's src/gpu.rs also uses.
[target.'cfg(windows)'.dependencies]
winsafe = { version = "0.0.29", default-features = false, features = ["kernel", "psapi"] }
# DirectWrite's system font collection (the desktop app's src/system_fonts.rs, shared): fonts that
# font services such as Adobe Fonts load in place (#579). MIT OR Apache-2.0.
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
+6 -4
View File
@@ -7,7 +7,7 @@
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data]
//! vectorcraft-cli info FILE
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
//! vectorcraft-cli perf [--paths 50000]
//! vectorcraft-cli perf [--paths 50000] [--deep]
//! ```
// Denied, not forbidden: the font listers shared with the desktop app allow it for their
// DirectWrite (Windows) and CoreText (macOS) calls, and nothing else may.
@@ -104,9 +104,11 @@ USAGE:
Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame
(warm), multithreaded and single-threaded.
vectorcraft-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.
vectorcraft-cli perf [--paths N] [--deep]
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder, handle
and segment drags) on a synthetic N-path document (default 50000). Exits non-zero if a
budget is exceeded, unless the report says the machine is busy. --deep also times work with
no budget yet and prints memory use (the working set on Windows).
";
/// The usage text plus the formats `document.open` reads and `document.export` writes.
+78 -5
View File
@@ -1,7 +1,8 @@
//! `vectorcraft-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.
//! Each row is the median of several runs. Timings are wall-clock: on a busy machine (a load
//! average above three quarters of the cores, or on Windows more than three quarters of the
//! cores busy over a second) they are noise, and the report says so.
use std::time::Instant;
@@ -61,6 +62,7 @@ fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 {
}
/// 1-minute load average (macOS / Linux), if available.
#[cfg(not(windows))]
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 {
@@ -70,6 +72,27 @@ fn load_average() -> Option<f64> {
text.split_whitespace().map(|t| t.trim_matches(|c| c == '{' || c == '}')).find_map(|t| t.parse().ok())
}
/// The cores busy on average over one second (Windows), if available: the share of the
/// processors' time that `GetSystemTimes` reports as kernel or user time but not as idle time,
/// times `cores`. A load average also counts the threads waiting for a core.
#[cfg(windows)]
fn busy_cores(cores: usize) -> Option<f64> {
let times = || winsafe::GetSystemTimes().ok().map(|(idle, kernel, user)| [idle, kernel, user].map(u64::from));
let before = times()?;
std::thread::sleep(std::time::Duration::from_secs(1));
busy_share(before, times()?).map(|share| share * cores as f64)
}
/// The share of the processors' time between two `[idle, kernel, user]` readings of
/// `GetSystemTimes` that was busy (kernel time includes idle time). It is None when no time
/// passed, a time went back, or the idle time exceeds the kernel and user time.
#[cfg(any(windows, test))]
fn busy_share([idle0, kernel0, user0]: [u64; 3], [idle1, kernel1, user1]: [u64; 3]) -> Option<f64> {
let total = kernel1.checked_sub(kernel0)?.checked_add(user1.checked_sub(user0)?)?;
let busy = total.checked_sub(idle1.checked_sub(idle0)?)?;
(total > 0).then(|| busy as f64 / total as f64)
}
/// Milliseconds per pointer move of a Direct Selection drag of a handle, then of a segment, of a
/// 5,000-anchor wave of smooth anchors added on top of the synthetic `n`-path document.
fn edit_drags(n: usize) -> Result<(f64, f64), String> {
@@ -114,12 +137,21 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> {
}
/// Resident memory of this process in MB (`ps`), if available.
#[cfg(not(windows))]
fn rss_mb() -> Option<f64> {
let pid = std::process::id().to_string();
let out = std::process::Command::new("ps").args(["-o", "rss=", "-p", &pid]).output().ok().filter(|o| o.status.success())?;
String::from_utf8_lossy(&out.stdout).trim().parse::<f64>().ok().map(|kb| kb / 1024.0)
}
/// The working set of this process in MB (`GetProcessMemoryInfo`), if available: its pages that
/// are in physical memory, the figure `ps` reports as resident memory elsewhere.
#[cfg(windows)]
fn rss_mb() -> Option<f64> {
let counters = winsafe::HPROCESS::GetCurrentProcess().GetProcessMemoryInfo().ok()?;
Some(counters.WorkingSetSize as f64 / (1024.0 * 1024.0))
}
/// `--deep`: tracked rows (no budget yet) for work the budgets above don't reach: editing a whole
/// selection, live effects, every Pathfinder operation, typing in a long story, and memory.
fn deep(n: usize) -> Result<(), String> {
@@ -243,8 +275,10 @@ fn deep(n: usize) -> Result<(), String> {
for (name, ms) in &rows {
outln!(" {name:<52} {ms:>10.2} ms");
}
// `rss_mb` reads the working set on Windows.
let memory = if cfg!(windows) { "working set" } else { "memory" };
for (what, mb) in &mem {
outln!(" memory {what:<45} {mb:>8.0} MB");
outln!(" {:<52} {mb:>8.0} MB", format!("{memory} {what}"));
}
Ok(())
}
@@ -261,8 +295,12 @@ pub fn run(args: &[String]) -> Result<(), String> {
}
}
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
let load = load_average();
outln!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
// The figure is the 1-minute load average, or on Windows the cores busy over one second.
#[cfg(not(windows))]
let (load, label) = (load_average(), "load average");
#[cfg(windows)]
let (load, label) = (busy_cores(cores), "cores busy");
outln!("VectorCraft performance budgets — {n} paths, {cores} cores, {label} {}", load.map_or("?".into(), |l| format!("{l:.1}")));
let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
if noisy {
outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
@@ -358,3 +396,38 @@ pub fn run(args: &[String]) -> Result<(), String> {
_ => Err(format!("{over} budget(s) exceeded")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn busy_share_leaves_out_idle_time() {
// In one second four processors log 40,000,000 ticks of 100 ns of kernel and user time,
// 10,000,000 of them idle.
assert_eq!(busy_share([7, 100, 50], [10_000_007, 25_000_100, 15_000_050]), Some(0.75));
// There is no share when no time passed, a time went back, or the idle time exceeds the
// kernel and user time.
assert_eq!(busy_share([7, 100, 50], [7, 100, 50]), None);
assert_eq!(busy_share([7, 100, 50], [8, 99, 60]), None);
assert_eq!(busy_share([0, 0, 0], [30, 10, 10]), None);
}
/// On Windows `busy_cores` gives a number between 0 and the cores.
#[cfg(windows)]
#[test]
fn windows_reads_how_many_cores_are_busy() {
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
let busy = busy_cores(cores);
assert!(busy.is_some_and(|b| (0.0..=cores as f64).contains(&b)), "{busy:?} of {cores}");
}
/// On Windows `rss_mb` gives this process's working set: more than 0 MB and less than
/// 10,000 MB.
#[cfg(windows)]
#[test]
fn windows_reads_the_working_set() {
let mb = rss_mb();
assert!(mb.is_some_and(|mb| mb > 0.0 && mb < 10_000.0), "{mb:?}");
}
}
+6
View File
@@ -83,6 +83,12 @@ fn help_lists_readable_formats() {
assert!(out.contains("Readable formats: .vectorcraft") && out.contains(".ait") && out.contains(".webp"), "{out}");
}
#[test]
fn help_lists_perf_deep() {
let out = ok(&["--help"]);
assert!(out.contains("vectorcraft-cli perf [--paths N] [--deep]") && out.contains("--deep also times"), "{out}");
}
/// `info` reports the import warnings: an EPS read only up to an error names it.
#[test]
fn info_reports_why_an_eps_read_partly() {