diff --git a/Cargo.lock b/Cargo.lock index 11db0f82..30cc4242 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4580,6 +4580,7 @@ dependencies = [ "vectorcraft-testkit", "vectorcraft-text", "windows", + "winsafe", ] [[package]] diff --git a/apps/vectorcraft-cli/Cargo.toml b/apps/vectorcraft-cli/Cargo.toml index 1d0e01ae..c31998d0 100644 --- a/apps/vectorcraft-cli/Cargo.toml +++ b/apps/vectorcraft-cli/Cargo.toml @@ -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] diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index f5dd5467..8c595421 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -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. diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index 97cd0647..0a59ed69 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -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 { 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 { 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 { + 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 { + 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 { 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::().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 { + 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:?}"); + } +} diff --git a/apps/vectorcraft-cli/tests/fileio.rs b/apps/vectorcraft-cli/tests/fileio.rs index 36c903b8..f23ab514 100644 --- a/apps/vectorcraft-cli/tests/fileio.rs +++ b/apps/vectorcraft-cli/tests/fileio.rs @@ -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() {