From 4e785b40ce1d07fabb4ba06646d43ce11e5a2710 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 03:12:49 -0700 Subject: [PATCH 1/2] Part of #4516: Windows renders start workers only for the cores the process may use The renderer sized its worker pool from available_parallelism, which on Windows counts every processor in the machine, also when the process's affinity mask allows fewer. A process allowed one core started 8 workers, and the renderer's flush spun on that core while the workers got no time, so renders crawled or stalled. The desktop app and vectorcraft-cli now read the affinity mask at startup (src/windows_cores.rs, shared) and pass the count to the renderer with vectorcraft_render::set_usable_cores. With one usable core the renderer renders on the calling thread. --- Cargo.lock | 1 + apps/vectorcraft-cli/Cargo.toml | 5 ++- apps/vectorcraft-cli/src/main.rs | 7 ++++ apps/vectorcraft/Cargo.toml | 6 ++- apps/vectorcraft/src/main.rs | 5 +++ apps/vectorcraft/src/windows_cores.rs | 59 +++++++++++++++++++++++++++ crates/render/src/lib.rs | 2 +- crates/render/src/threads.rs | 44 ++++++++++++++++++-- 8 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 apps/vectorcraft/src/windows_cores.rs diff --git a/Cargo.lock b/Cargo.lock index 62f084afc..ff1d18be2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6065,6 +6065,7 @@ dependencies = [ "smithay-clipboard", "socket2", "vectorcraft-engine", + "vectorcraft-render", "vectorcraft-text", "vectorcraft-ui-egui", "wayland-backend", diff --git a/apps/vectorcraft-cli/Cargo.toml b/apps/vectorcraft-cli/Cargo.toml index cb9183315..8f39e3b58 100644 --- a/apps/vectorcraft-cli/Cargo.toml +++ b/apps/vectorcraft-cli/Cargo.toml @@ -34,8 +34,9 @@ winsafe = { version = "0.0.29", default-features = false, features = ["kernel", # DirectWrite's system font collection and GDI's font list (the desktop app's src/system_fonts.rs, # shared): fonts that font services such as Adobe Fonts load in place (#579, #1258). MIT OR -# Apache-2.0. -windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi"] } +# Apache-2.0. Win32_System_Threading has `GetProcessAffinityMask`, which the desktop app's +# src/windows_cores.rs, shared, uses to count the cores the process may run on. +windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Threading"] } # CoreText's font manager (the desktop app's src/mac_fonts.rs, shared): fonts that apps and font # managers register from their own folders (#579). Zlib OR Apache-2.0 OR MIT. diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index bba1976c1..b7334586c 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -49,6 +49,10 @@ mod prefs_dir; #[cfg(windows)] #[path = "../../vectorcraft/src/system_fonts.rs"] mod system_fonts; +/// The desktop app's affinity-mask reader, shared: renders start workers only for usable cores. +#[cfg(windows)] +#[path = "../../vectorcraft/src/windows_cores.rs"] +mod windows_cores; use serde_json::{Value, json}; use vectorcraft_engine::file_access::{self, AutomationRoots}; @@ -161,6 +165,9 @@ fn help() -> Result<(), String> { } fn main() -> ExitCode { + // Before the first render: the cores the process may run on. + #[cfg(windows)] + windows_cores::install(); // Before any font lookup: the fonts font services load (#579). #[cfg(windows)] system_fonts::install(); diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 0657c93c8..7af8c51ef 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -54,7 +54,11 @@ pollster = "1" # Win32_UI_WindowsAndMessaging has `MSG` and `WM_POINTERDOWN`, which src/windows_stylus.rs uses to # read a pen's barrel button from its pointer messages. Win32_UI_Input_Pointer has # `GetPointerPenInfo`, which it uses to read the pen's pressure and which end of the pen is down. -windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } +# Win32_System_Threading has `GetProcessAffinityMask`, which src/windows_cores.rs uses to count the +# cores the process may run on. +windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Threading", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } +# The renderer's worker count follows those cores (src/windows_cores.rs). +vectorcraft-render = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] # The macOS menu bar (src/mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index e90977726..1e2ab4fae 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -50,6 +50,8 @@ mod system_fonts; mod tablet; mod window; mod window_frame; +#[cfg(windows)] +mod windows_cores; mod windows_stylus; mod xwayland; @@ -184,6 +186,9 @@ fn main() -> std::process::ExitCode { // Before desktop services, so every start-up warning is recorded (`logging`). let logger = logging::install(); vectorcraft_ui_egui::i18n::detect_system_lang_in_background(); + // Before the first render: the cores the process may run on. + #[cfg(windows)] + windows_cores::install(); // Before the first font scan (the app's start): the fonts font services load (#579). #[cfg(windows)] system_fonts::install(); diff --git a/apps/vectorcraft/src/windows_cores.rs b/apps/vectorcraft/src/windows_cores.rs new file mode 100644 index 000000000..97d040550 --- /dev/null +++ b/apps/vectorcraft/src/windows_cores.rs @@ -0,0 +1,59 @@ +//! Windows: the cores the process may run on, from its affinity mask, for the renderer's worker +//! count (`vectorcraft_render::set_usable_cores`). `available_parallelism` counts every processor +//! in the machine, also when the process may run on fewer (started with `start /affinity`, or +//! pinned by a launcher). The desktop app and `vectorcraft-cli` (exports, MCP) both install it; the +//! CLI shares this file. +//! +//! `GetProcessAffinityMask` is a C API with no safe binding, hence the scoped `unsafe_code` +//! allowance. +#![allow(unsafe_code)] + +use windows::Win32::System::Threading::{GetCurrentProcess, GetProcessAffinityMask}; + +/// Tells the renderer how many cores the process may run on. +pub fn install() { + vectorcraft_render::set_usable_cores(usable_cores()); +} + +/// The number of cores in the process's affinity mask, or `None` when Windows reports none (a +/// process with threads in more than one processor group). +fn usable_cores() -> Option { + let (mut process, mut system) = (0usize, 0usize); + // SAFETY: `GetCurrentProcess` returns a pseudo-handle that needs no closing, and both pointers + // point at live `usize`s for the length of the call. + unsafe { GetProcessAffinityMask(GetCurrentProcess(), &mut process, &mut system) }.ok()?; + (process != 0).then(|| process.count_ones() as usize) +} + +#[cfg(test)] +mod tests { + /// A process that may run on one core renders on the calling thread. With workers, the + /// renderer's flush spins on that core while the workers it waits for get no time, and a render + /// crawls or stalls. + #[test] + fn a_process_limited_to_one_core_starts_no_render_workers() { + const CHILD: &str = "VECTORCRAFT_TEST_ONE_CORE"; + const NAME: &str = "windows_cores::tests::a_process_limited_to_one_core_starts_no_render_workers"; + if std::env::var_os(CHILD).is_some() { + assert_eq!(super::usable_cores(), Some(1)); + super::install(); + assert_eq!(vectorcraft_render::default_threads(), 0); + return; + } + // `start /affinity 1` runs this test again in a process allowed only the first core. + use std::os::windows::process::CommandExt; + let exe = std::env::current_exe().unwrap(); + let output = std::process::Command::new("cmd") + .raw_arg(format!("/C start \"\" /affinity 1 /wait /b \"{}\" --exact {NAME} --nocapture", exe.display())) + .env(CHILD, "1") + .env_remove("VECTORCRAFT_RENDER_THREADS") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"), + "one-core test failed:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr), + ); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d5c601dc1..8bf6b0d23 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -227,6 +227,6 @@ pub(crate) use text_geom::is_opentype_alternate; #[cfg(test)] use text_geom::text_geom; pub(crate) use threads::single_threaded_context; -pub use threads::{default_threads, set_default_threads}; +pub use threads::{default_threads, set_default_threads, set_usable_cores}; #[cfg(test)] mod tests; diff --git a/crates/render/src/threads.rs b/crates/render/src/threads.rs index eb1fb66ea..dfd6bf757 100644 --- a/crates/render/src/threads.rs +++ b/crates/render/src/threads.rs @@ -9,14 +9,25 @@ pub(crate) fn single_threaded_context(w: u16, h: u16) -> RenderContext { /// Preferred rasterizer thread count set by the app (Preferences → Performance); negative = automatic. static THREADS_OVERRIDE: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1); +/// The cores the process may run on, as the app read them from the system; 0 = not known. +static USABLE_CORES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + /// Override the worker-thread count used by renderers created from now on (`None` = automatic). pub fn set_default_threads(n: Option) { THREADS_OVERRIDE.store(n.map_or(-1, i32::from), std::sync::atomic::Ordering::Relaxed); } -/// Rasterizer worker threads: 0 on wasm; otherwise one per core up to 8, leaving a core free. On a -/// 10-core machine 8 workers render a 50,000-path page 1.4× faster than 4 (9 are slower again); -/// the pixels don't depend on the count (`pixels_do_not_depend_on_the_thread_count`). +/// The number of cores the process may run on, when the app knows a smaller number than +/// `available_parallelism` gives (`None` = not known). On Windows `available_parallelism` counts +/// every processor in the machine, also when the process's affinity mask allows fewer. +pub fn set_usable_cores(n: Option) { + USABLE_CORES.store(n.unwrap_or(0), std::sync::atomic::Ordering::Relaxed); +} + +/// Rasterizer worker threads: 0 on wasm; otherwise one per core the process may run on, up to 8, +/// leaving a core free. On a 10-core machine 8 workers render a 50,000-path page 1.4× faster than 4 +/// (9 are slower again); the pixels don't depend on the count +/// (`pixels_do_not_depend_on_the_thread_count`). pub fn default_threads() -> u16 { #[cfg(target_arch = "wasm32")] { @@ -31,6 +42,31 @@ pub fn default_threads() -> u16 { if std::env::var_os("VECTORCRAFT_RENDER_THREADS").is_some() { return std::env::var("VECTORCRAFT_RENDER_THREADS").ok().and_then(|v| v.parse().ok()).unwrap_or(0); } - std::thread::available_parallelism().map(|n| (n.get().saturating_sub(1)).min(8) as u16).unwrap_or(0) + let usable = USABLE_CORES.load(std::sync::atomic::Ordering::Relaxed); + std::thread::available_parallelism().map(|n| automatic_threads(n.get(), usable)).unwrap_or(0) + } +} + +/// [`default_threads`] when automatic, on `cpus` processors of which the process may use `usable` +/// (0 = all). +fn automatic_threads(cpus: usize, usable: usize) -> u16 { + let cores = if usable == 0 { cpus } else { cpus.min(usable) }; + (cores.saturating_sub(1)).min(8) as u16 +} + +#[cfg(test)] +mod tests { + use super::automatic_threads; + + /// A process that may run on one core renders on the calling thread. With workers, vello_cpu's + /// flush spins on that core while the workers it waits for get no time, and a render crawls or + /// stalls. + #[test] + fn workers_follow_the_cores_the_process_may_use() { + assert_eq!(automatic_threads(24, 0), 8); + assert_eq!(automatic_threads(24, 1), 0); + assert_eq!(automatic_threads(24, 4), 3); + assert_eq!(automatic_threads(4, 16), 3); + assert_eq!(automatic_threads(1, 0), 0); } } From da9eaaacf8c9742b2cd07badd95c7c77d335a297 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 31 Oct 2026 19:43:13 +0700 Subject: [PATCH 2/2] Windows cores: test the affinity mask in-process A test that spawned the test binary with `start /affinity 1` ran while the control server's socket tests held connections; the child inherited them and reset one (idle_expiry_closes_without_writing_a_line failed every full run). The one-core worker count stays covered by `automatic_threads`. --- apps/vectorcraft/src/windows_cores.rs | 35 ++++++--------------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/apps/vectorcraft/src/windows_cores.rs b/apps/vectorcraft/src/windows_cores.rs index 97d040550..b35c0876c 100644 --- a/apps/vectorcraft/src/windows_cores.rs +++ b/apps/vectorcraft/src/windows_cores.rs @@ -27,33 +27,14 @@ fn usable_cores() -> Option { #[cfg(test)] mod tests { - /// A process that may run on one core renders on the calling thread. With workers, the - /// renderer's flush spins on that core while the workers it waits for get no time, and a render - /// crawls or stalls. + /// The mask Windows reports for this process gives at least one core and no more than the + /// machine has. (A child process started with `start /affinity` would test a one-core mask + /// end to end, but a process spawned while other tests hold sockets inherits them and resets + /// their connections; `automatic_threads` covers the one-core count.) #[test] - fn a_process_limited_to_one_core_starts_no_render_workers() { - const CHILD: &str = "VECTORCRAFT_TEST_ONE_CORE"; - const NAME: &str = "windows_cores::tests::a_process_limited_to_one_core_starts_no_render_workers"; - if std::env::var_os(CHILD).is_some() { - assert_eq!(super::usable_cores(), Some(1)); - super::install(); - assert_eq!(vectorcraft_render::default_threads(), 0); - return; - } - // `start /affinity 1` runs this test again in a process allowed only the first core. - use std::os::windows::process::CommandExt; - let exe = std::env::current_exe().unwrap(); - let output = std::process::Command::new("cmd") - .raw_arg(format!("/C start \"\" /affinity 1 /wait /b \"{}\" --exact {NAME} --nocapture", exe.display())) - .env(CHILD, "1") - .env_remove("VECTORCRAFT_RENDER_THREADS") - .output() - .unwrap(); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"), - "one-core test failed:\n{stdout}\n{}", - String::from_utf8_lossy(&output.stderr), - ); + fn the_affinity_mask_gives_the_cores_the_process_may_use() { + let cores = super::usable_cores().unwrap(); + let all = std::thread::available_parallelism().unwrap().get(); + assert!((1..=all).contains(&cores), "{cores} of {all}"); } }