CLI: perf on Windows reports the cores busy and its working set, and the help lists --deep

On Windows, perf printed "load average ?": load_average runs sysctl
or reads /proc/loadavg, and Windows has neither. The warning for a
busy machine could not print, so a run over budget on a busy machine
failed, where macOS and Linux warn and exit 0. perf --deep printed no
memory rows, because rss_mb runs ps. --help did not list --deep.

On Windows, perf now reads GetSystemTimes twice, one second apart,
and its first line gives the cores busy on average over that second
where macOS and Linux give the load average. It warns above three
quarters of the cores, as on macOS and Linux: with all 32 logical
processors of a test machine kept busy, the line read "cores busy
32.0" and a run with three budgets over exited 0. The --deep memory
rows give the working set (GetProcessMemoryInfo). Both calls use
winsafe 0.0.29, which Cargo.lock already lists for the desktop app;
the CLI depends on it on Windows with the kernel and psapi features.
The help lists --deep and says that a run over budget on a busy
machine exits 0. perf's report on macOS and Linux does not change.
This commit is contained in:
dotCooCoo authored and bflatastic committed 2026-10-13 05:02:25 +07:00
1 parent aebeb66268
commit 22e06db73d
5 files changed
+97 -9

No files matched your search

Generated
+1
View File
@@ -4580,6 +4580,7 @@ dependencies = [
"vectorcraft-testkit", "vectorcraft-testkit",
"vectorcraft-text", "vectorcraft-text",
"windows", "windows",
"winsafe",
] ]
[[package]] [[package]]
+6
View File
@@ -26,6 +26,12 @@ vectorcraft-text = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
log = { 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 # 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. # font services such as Adobe Fonts load in place (#579). MIT OR Apache-2.0.
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies] [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 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 info FILE
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5] //! 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 // 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. // 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 Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame
(warm), multithreaded and single-threaded. (warm), multithreaded and single-threaded.
vectorcraft-cli perf [--paths N] vectorcraft-cli perf [--paths N] [--deep]
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder) on a Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder, handle
synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded. 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. /// 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. //! `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 //! Each row is the median of several runs. Timings are wall-clock: on a busy machine (a load
//! average above the core count) they are noise, and the report says so. //! 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; 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. /// 1-minute load average (macOS / Linux), if available.
#[cfg(not(windows))]
fn load_average() -> Option<f64> { fn load_average() -> Option<f64> {
let out = std::process::Command::new("sysctl").args(["-n", "vm.loadavg"]).output().ok().filter(|o| o.status.success()); let out = std::process::Command::new("sysctl").args(["-n", "vm.loadavg"]).output().ok().filter(|o| o.status.success());
let text = match out { 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()) 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 /// 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. /// 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> { 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. /// Resident memory of this process in MB (`ps`), if available.
#[cfg(not(windows))]
fn rss_mb() -> Option<f64> { fn rss_mb() -> Option<f64> {
let pid = std::process::id().to_string(); 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())?; 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) 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 /// `--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. /// selection, live effects, every Pathfinder operation, typing in a long story, and memory.
fn deep(n: usize) -> Result<(), String> { fn deep(n: usize) -> Result<(), String> {
@@ -243,8 +275,10 @@ fn deep(n: usize) -> Result<(), String> {
for (name, ms) in &rows { for (name, ms) in &rows {
outln!(" {name:<52} {ms:>10.2} ms"); 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 { for (what, mb) in &mem {
outln!(" memory {what:<45} {mb:>8.0} MB"); outln!(" {:<52} {mb:>8.0} MB", format!("{memory} {what}"));
} }
Ok(()) 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 cores = std::thread::available_parallelism().map_or(1, |c| c.get());
let load = load_average(); // The figure is the 1-minute load average, or on Windows the cores busy over one second.
outln!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}"))); #[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); let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
if noisy { if noisy {
outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy."); 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")), _ => 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}"); 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. /// `info` reports the import warnings: an EPS read only up to an error names it.
#[test] #[test]
fn info_reports_why_an_eps_read_partly() { fn info_reports_why_an_eps_read_partly() {