diff --git a/.changeset/size-napi-rayon-pool.md b/.changeset/size-napi-rayon-pool.md new file mode 100644 index 0000000000..14e20d0974 --- /dev/null +++ b/.changeset/size-napi-rayon-pool.md @@ -0,0 +1,5 @@ +--- +"@pnpm/napi": patch +--- + +The Node.js addon now sizes rayon's thread pool according to the engine thread-pool sizing policy [pnpm/tasks#54](https://github.com/pnpm/tasks/issues/54). diff --git a/Cargo.lock b/Cargo.lock index 3db00aa1bd..0ae4d7a0dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5628,6 +5628,7 @@ dependencies = [ "pnpm-testing-utils", "pnpm-workspace", "pretty_assertions", + "rayon", "serde", "serde_json", "tempfile", diff --git a/pnpm/crates/cli/src/lib.rs b/pnpm/crates/cli/src/lib.rs index 0d5eb7f7b3..adbaefe6dc 100644 --- a/pnpm/crates/cli/src/lib.rs +++ b/pnpm/crates/cli/src/lib.rs @@ -57,6 +57,7 @@ use config_overrides::ConfigOverrides; use flag_relocation::relocate_pre_subcommand_flags; use miette::set_panic_hook; use pnpm_diagnostics::{enable_tracing_by_env, install_report_handler}; +use pnpm_package_manager::configure_rayon_pool; use state::State; use std::{ffi::OsString, future::Future, path::Path, process::ExitCode}; @@ -376,83 +377,6 @@ fn inject_alias_subcommand(exe_name: Option<&str>, mut argv: Vec) -> V argv } -/// Size rayon's global pool with [`rayon_pool_size`]. -/// -/// Must run before anything touches rayon. The first parallel iterator -/// builds the global pool at rayon's default size, after which this -/// call can no longer size it, so debug builds assert that it did. The -/// repeat-install fast path uses rayon for workspace discovery. -/// -/// Deliberately NOT communicated via the `RAYON_NUM_THREADS` -/// environment variable: a process-env write would leak into every -/// child the install spawns (lifecycle scripts, `node --version`, -/// git), and pnpm exposes no such variable to scripts. An explicit -/// `RAYON_NUM_THREADS` from the caller is honoured by skipping the -/// override. -/// -/// Use [`std::thread::available_parallelism`] rather than the -/// workspace's existing `num_cpus::get()` so cgroup / CPU-quota -/// limits in containers and CI runners are respected — `num_cpus` -/// reports the host's logical CPU count, which on a quota-limited -/// runner can spin up far more rayon threads than the kernel will -/// actually schedule onto our cores. -fn configure_rayon_pool() { - if std::env::var_os("RAYON_NUM_THREADS").is_some() { - return; - } - let parallelism = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get); - let built = rayon::ThreadPoolBuilder::new() - .num_threads(rayon_pool_size(parallelism, RAYON_THREADS_PER_CORE)) - .build_global(); - let configured = built.is_ok(); - debug_assert!(configured, "rayon's global pool was built before it was configured: {built:?}"); -} - -/// `threads_per_core × parallelism`, kept between [`MIN_RAYON_THREADS`] -/// and [`MAX_RAYON_THREADS`]. See [`RAYON_THREADS_PER_CORE`] for the -/// multiplier. The link phase is dominated by clonefile / -/// hardlink syscalls that block the calling thread on the kernel's -/// metadata journal, not by CPU work, so oversubscribing CPUs gives -/// more in-flight syscalls and a higher effective throughput. -/// Empirically sweeping 4-200 threads on a 1352-package warm install -/// on macOS APFS, 2× was the knee — fewer threads underutilize the -/// journal, way more (100+) loses to context switching and per-thread -/// fixed costs (`user` time scales linearly past 50 without any -/// wall-time payoff). That knee holds up to the ceiling below. Past 16 -/// threads, 2× no longer pays off. -/// -/// **Floor of 4 threads is intentional.** A 1-2-CPU CI runner left -/// at `2 × parallelism` would be capped to 2-4 rayon threads, and -/// at that point we go back to the original "one rayon thread is -/// blocked on a `clonefile` while the next fully-ready snapshot -/// can't even start" pattern that the 2× tuning is trying to -/// avoid. The kernel metadata journal is the bottleneck even on -/// small hosts, so a small intentional oversubscription -/// (`max(4, 2 × parallelism)`) is a better trade than respecting the -/// quota literally. -/// -/// **Ceiling of 16 threads.** Past 16, the extra workers add system -/// time without shortening the install. A warm-install sweep (nuxt, next, nitro -/// fixtures) found no machine where 2× beat 16 threads: a 32-vCPU -/// Linux runner kept its wall time and halved its system time, a -/// 16-vCPU Windows runner got 10% faster, and a 10-core M1 Max, the -/// one host where 2× beat 8 threads, was unchanged at 16 -/// (pnpm/tasks#51). A ceiling of 8 cut Linux system time further and -/// sped up Windows, but cost that Mac 19%. -fn rayon_pool_size(parallelism: usize, threads_per_core: usize) -> usize { - parallelism.saturating_mul(threads_per_core).clamp(MIN_RAYON_THREADS, MAX_RAYON_THREADS) -} - -/// Two threads per core, except on Windows, where the sweeps mostly -/// favoured one (pnpm/tasks#52). Warm frozen installs were 4-5% faster -/// at 1× on 4- and 8-vCPU runners. A fresh install of the 1352-package -/// benchmark fixture took 3.9 s at 1× and 4.5 s at 2× on a 4-vCPU -/// runner, and was even on an 8-vCPU one. A 16-vCPU runner was fastest -/// at 4 threads, below what this multiplier gives it. -const RAYON_THREADS_PER_CORE: usize = if cfg!(windows) { 1 } else { 2 }; -const MIN_RAYON_THREADS: usize = 4; -const MAX_RAYON_THREADS: usize = 16; - #[cfg(test)] mod tests; diff --git a/pnpm/crates/cli/src/tests.rs b/pnpm/crates/cli/src/tests.rs index 604ff4a839..f96911b3f3 100644 --- a/pnpm/crates/cli/src/tests.rs +++ b/pnpm/crates/cli/src/tests.rs @@ -1,4 +1,5 @@ -use super::{inject_alias_subcommand, parse_cli_args, prepare_cli_argv, rayon_pool_size}; +use super::{inject_alias_subcommand, parse_cli_args, prepare_cli_argv}; +use pnpm_package_manager::rayon_pool_size; use std::ffi::OsString; fn argv(parts: &[&str]) -> Vec { diff --git a/pnpm/crates/napi/Cargo.toml b/pnpm/crates/napi/Cargo.toml index 899fdfb992..a038f37baf 100644 --- a/pnpm/crates/napi/Cargo.toml +++ b/pnpm/crates/napi/Cargo.toml @@ -63,6 +63,7 @@ napi-build = { workspace = true } mockito = { workspace = true } pretty_assertions = { workspace = true } pnpm-testing-utils = { workspace = true } +rayon = { workspace = true } tempfile = { workspace = true } [target.'cfg(not(target_family = "wasm"))'.dependencies] diff --git a/pnpm/crates/napi/src/dependents.rs b/pnpm/crates/napi/src/dependents.rs index cc4556023e..e3711cb9c0 100644 --- a/pnpm/crates/napi/src/dependents.rs +++ b/pnpm/crates/napi/src/dependents.rs @@ -102,6 +102,7 @@ pub struct RenderDependentsInput { /// which is an answer, not an error. #[napi] pub async fn get_dependents(options: DependentsOptions) -> napi::Result { + pnpm_package_manager::configure_rayon_pool(); tokio::task::spawn_blocking(move || build_trees(&options)).await .map_err(|join_error| { napi::Error::from_reason(format!( diff --git a/pnpm/crates/napi/src/install.rs b/pnpm/crates/napi/src/install.rs index 43c6b5fba7..131c07279d 100644 --- a/pnpm/crates/napi/src/install.rs +++ b/pnpm/crates/napi/src/install.rs @@ -99,6 +99,7 @@ pub async fn install( read_package_batch_hook: Option, on_output: Option, ) -> napi::Result { + pnpm_package_manager::configure_rayon_pool(); let _guard = engine_call_lock().lock().await; let renderer = build_renderer(&options, on_output); let (tx, rx) = tokio::sync::oneshot::channel(); diff --git a/pnpm/crates/napi/src/install/peer_issues.rs b/pnpm/crates/napi/src/install/peer_issues.rs index 4d01fae054..4e0f4247bc 100644 --- a/pnpm/crates/napi/src/install/peer_issues.rs +++ b/pnpm/crates/napi/src/install/peer_issues.rs @@ -7,6 +7,7 @@ use super::{ pub async fn get_peer_dependency_issues( options: PeerIssuesOptions, ) -> napi::Result { + pnpm_package_manager::configure_rayon_pool(); let _guard = engine_call_lock().lock().await; let (tx, rx) = tokio::sync::oneshot::channel(); std::thread::Builder::new() diff --git a/pnpm/crates/napi/src/install/rebuild.rs b/pnpm/crates/napi/src/install/rebuild.rs index b323445340..d90fe7eddd 100644 --- a/pnpm/crates/napi/src/install/rebuild.rs +++ b/pnpm/crates/napi/src/install/rebuild.rs @@ -11,6 +11,7 @@ pub async fn rebuild( selected_names: Option>, on_output: Option, ) -> napi::Result<()> { + pnpm_package_manager::configure_rayon_pool(); let _guard = engine_call_lock().lock().await; let renderer = build_renderer(&options, on_output); let (tx, rx) = tokio::sync::oneshot::channel(); diff --git a/pnpm/crates/napi/src/lib.rs b/pnpm/crates/napi/src/lib.rs index 0a1177b574..5d0a22a865 100644 --- a/pnpm/crates/napi/src/lib.rs +++ b/pnpm/crates/napi/src/lib.rs @@ -66,6 +66,12 @@ pub fn engine_version() -> &'static str { /// Honor the same `TRACE` env var the pacquet CLI honors: an addon /// embedded in a Node host has no `main` of its own, so the subscriber /// is installed when the module loads. +/// +/// Rayon's global thread pool is deliberately NOT initialized here: +/// `module_init` runs during `dlopen` while the dynamic linker lock is held. +/// Spawning rayon worker threads here would block waiting for the dynamic +/// linker lock, deadlocking the process. The pool is configured upon the first +/// entry point call instead. #[napi_derive::module_init] fn init_tracing() { pnpm_diagnostics::enable_tracing_by_env(); @@ -76,3 +82,5 @@ fn init_tracing() { /// self-contained. See the module for the full rationale. #[cfg(test)] mod napi_runtime_test_stubs; +#[cfg(test)] +mod tests; diff --git a/pnpm/crates/napi/src/lockfile.rs b/pnpm/crates/napi/src/lockfile.rs index adea34b24a..7a399f2e7b 100644 --- a/pnpm/crates/napi/src/lockfile.rs +++ b/pnpm/crates/napi/src/lockfile.rs @@ -77,6 +77,7 @@ pub struct WriteLockfileOptions { /// Inputs for [`filter_lockfile_by_importers`]. #[napi(object)] +#[derive(Default)] pub struct FilterLockfileOptions { /// Whether the listed importers keep their `dependencies`. Defaults to /// `true`. @@ -105,6 +106,7 @@ pub struct FilterLockfileOptions { pub async fn read_lockfile( options: ReadLockfileOptions, ) -> napi::Result> { + pnpm_package_manager::configure_rayon_pool(); let kind = LockfileKind::parse(options.kind.as_deref())?; let path = lockfile_path(&options.dir, options.modules_dir.as_deref(), &kind); let loaded = tokio::task::spawn_blocking(move || Lockfile::load_from_path(&path)) @@ -124,6 +126,7 @@ pub async fn read_lockfile( /// Write the lockfile, formatted exactly as an install writes it. #[napi] pub async fn write_lockfile(options: WriteLockfileOptions) -> napi::Result<()> { + pnpm_package_manager::configure_rayon_pool(); let kind = LockfileKind::parse(options.kind.as_deref())?; let path = lockfile_path(&options.dir, options.modules_dir.as_deref(), &kind); let lockfile: Lockfile = serde_json::from_value(options.lockfile) @@ -137,6 +140,32 @@ pub async fn write_lockfile(options: WriteLockfileOptions) -> napi::Result<()> { .map_err(|error| to_napi_error(&error)) } +fn into_filter_options(options: Option) -> FilterByImportersOptions { + let options = options.unwrap_or_default(); + let skipped: HashSet = options.skipped + .unwrap_or_default() + .iter() + // An unparsable dep path matches no snapshot key, so skipping it + // is a no-op either way; dropping it keeps a stale entry in a + // host's skip list from failing the whole call. + .filter_map(|dep_path| dep_path.parse().ok()) + .collect(); + FilterByImportersOptions { + include: IncludedDependencies { + dependencies: options.include_dependencies.unwrap_or(true), + dev_dependencies: options.include_dev_dependencies.unwrap_or(true), + optional_dependencies: options.include_optional_dependencies.unwrap_or(true), + }, + skipped, + fail_on_missing_dependencies: options.fail_on_missing_dependencies.unwrap_or(false), + peer_edges: PeerEdgeOptions { + resolve_peers_from_workspace_root: options + .resolve_peers_from_workspace_root + .unwrap_or(false), + }, + } +} + /// The lockfile narrowed to what `importerIds` reaches: those importers /// keep only the dependency groups asked for, and `packages` / `snapshots` /// are pruned to the transitive closure of what they still depend on. @@ -149,46 +178,14 @@ pub fn filter_lockfile_by_importers( importer_ids: Vec, options: Option, ) -> napi::Result { + pnpm_package_manager::configure_rayon_pool(); let lockfile: Lockfile = serde_json::from_value(lockfile) .map_err(|err| { napi::Error::from_reason(format!("the lockfile argument is not a lockfile: {err}")) })?; - let options = options.unwrap_or(FilterLockfileOptions { - include_dependencies: None, - include_dev_dependencies: None, - include_optional_dependencies: None, - skipped: None, - fail_on_missing_dependencies: None, - resolve_peers_from_workspace_root: None, - }); - let skipped: HashSet = options.skipped - .unwrap_or_default() - .iter() - // An unparsable dep path matches no snapshot key, so skipping it - // is a no-op either way; dropping it keeps a stale entry in a - // host's skip list from failing the whole call. - .filter_map(|dep_path| dep_path.parse().ok()) - .collect(); + let filter_options = into_filter_options(options); let filtered = lockfile - .filter_by_importers( - importer_ids, - &FilterByImportersOptions { - include: IncludedDependencies { - dependencies: options.include_dependencies.unwrap_or(true), - dev_dependencies: options.include_dev_dependencies.unwrap_or(true), - optional_dependencies: options.include_optional_dependencies.unwrap_or(true), - }, - skipped, - fail_on_missing_dependencies: options - .fail_on_missing_dependencies - .unwrap_or(false), - peer_edges: PeerEdgeOptions { - resolve_peers_from_workspace_root: options - .resolve_peers_from_workspace_root - .unwrap_or(false), - }, - }, - ) + .filter_by_importers(importer_ids, &filter_options) .map_err(|error| to_napi_error(&error))?; serde_json::to_value(filtered) .map_err(|err| napi::Error::from_reason(format!("serializing the lockfile: {err}"))) diff --git a/pnpm/crates/napi/src/pack.rs b/pnpm/crates/napi/src/pack.rs index 618ae3af68..e8acdd99e9 100644 --- a/pnpm/crates/napi/src/pack.rs +++ b/pnpm/crates/napi/src/pack.rs @@ -55,6 +55,7 @@ pub struct PackResult { #[napi] pub async fn pack(options: PackOptions, on_log: Option) -> napi::Result { + pnpm_package_manager::configure_rayon_pool(); // Share the serialization lock with install/rebuild: they all drive the // same process-global log sink, so overlapping calls would misroute events. let _guard = engine_call_lock().lock().await; diff --git a/pnpm/crates/napi/src/read_config.rs b/pnpm/crates/napi/src/read_config.rs index e661dab783..9809d793dd 100644 --- a/pnpm/crates/napi/src/read_config.rs +++ b/pnpm/crates/napi/src/read_config.rs @@ -108,6 +108,7 @@ pub struct ResolvedConfig { #[napi(js_name = "readConfig")] pub fn read_config(options: ReadConfigOptions) -> napi::Result { + pnpm_package_manager::configure_rayon_pool(); let dir = std::path::PathBuf::from(options.dir); let config = resolve_config(&dir, &ConfigOverlay::default()).map_err(|error| to_napi_error(&error))?; diff --git a/pnpm/crates/napi/src/resolve.rs b/pnpm/crates/napi/src/resolve.rs index 85b517d23c..033b4872c1 100644 --- a/pnpm/crates/napi/src/resolve.rs +++ b/pnpm/crates/napi/src/resolve.rs @@ -65,6 +65,7 @@ pub async fn resolve_dependency( wanted: WantedDependencyInput, options: ResolveDependencyOptions, ) -> napi::Result { + pnpm_package_manager::configure_rayon_pool(); let (tx, rx) = tokio::sync::oneshot::channel(); std::thread::Builder::new() .name("pnpm-napi-resolve".to_string()) diff --git a/pnpm/crates/napi/src/tests.rs b/pnpm/crates/napi/src/tests.rs new file mode 100644 index 0000000000..68b70b8675 --- /dev/null +++ b/pnpm/crates/napi/src/tests.rs @@ -0,0 +1,39 @@ +use crate::read_config::{ReadConfigOptions, read_config}; + +#[test] +fn addon_entry_point_configures_rayon_pool() { + const CHILD: &str = "PNPM_NAPI_RAYON_POOL_TEST_CHILD"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "tests::addon_entry_point_configures_rayon_pool"]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + return; + } + let temp = tempfile::tempdir().unwrap(); + let config = read_config(ReadConfigOptions { + dir: temp + .path() + .to_str() + .unwrap() + .to_string(), + }) + .expect("read_config must succeed"); + let _ = config.registries; + let current_threads = rayon::current_num_threads(); + let parallelism = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get); + let override_threads = std::env::var("RAYON_NUM_THREADS") + .ok() + .and_then(|value| value.parse::().ok()); + let expected = match override_threads { + Some(0) => parallelism, + Some(threads) => threads, + None => pnpm_package_manager::rayon_pool_size( + parallelism, + pnpm_package_manager::RAYON_THREADS_PER_CORE, + ), + }; + assert_eq!(current_threads, expected); +} diff --git a/pnpm/crates/package-manager/src/lib.rs b/pnpm/crates/package-manager/src/lib.rs index 6dd41700f5..a31c6dd77d 100644 --- a/pnpm/crates/package-manager/src/lib.rs +++ b/pnpm/crates/package-manager/src/lib.rs @@ -29,6 +29,7 @@ pub use pnpm_patching::{ PatchCommitError, PkgFilesForDiff, diff_folders, prepare_pkg_files_for_diff, }; pub use prefetching_resolver::*; +pub use rayon_pool::*; pub use remove::*; pub use resolution_observer::*; pub use resolution_policy::{PickPolicy, create_configured_registry_resolver}; @@ -78,6 +79,7 @@ mod peer_dependency_issues; mod prefetching_resolver; mod prune_merged_branch_lockfile; mod prune_virtual_store; +mod rayon_pool; mod remove; mod resolution_observer; mod resolution_policy; diff --git a/pnpm/crates/package-manager/src/rayon_pool.rs b/pnpm/crates/package-manager/src/rayon_pool.rs new file mode 100644 index 0000000000..b53e3d61c2 --- /dev/null +++ b/pnpm/crates/package-manager/src/rayon_pool.rs @@ -0,0 +1,94 @@ +//! Rayon thread pool sizing policy and configuration. +//! +//! Sizes rayon's global pool for installation workloads across the CLI +//! and the Node.js addon. + +use std::sync::Once; + +/// Size rayon's global pool with [`rayon_pool_size`]. +/// +/// Must run before anything touches rayon. The first parallel iterator +/// builds the global pool at rayon's default size, after which this +/// call can no longer size it, so debug builds assert that it did. The +/// repeat-install fast path uses rayon for workspace discovery. +/// +/// Deliberately NOT communicated via the `RAYON_NUM_THREADS` +/// environment variable: a process-env write would leak into every +/// child the install spawns (lifecycle scripts, `node --version`, +/// git), and pnpm exposes no such variable to scripts. An explicit +/// `RAYON_NUM_THREADS` from the caller is honoured by skipping the +/// override. +/// +/// Use [`std::thread::available_parallelism`] rather than the +/// workspace's existing `num_cpus::get()` so cgroup / CPU-quota +/// limits in containers and CI runners are respected — `num_cpus` +/// reports the host's logical CPU count, which on a quota-limited +/// runner can spin up far more rayon threads than the kernel will +/// actually schedule onto our cores. +pub fn configure_rayon_pool() { + static INITIALIZE: Once = Once::new(); + INITIALIZE.call_once(|| { + if std::env::var_os("RAYON_NUM_THREADS").is_some() { + return; + } + let parallelism = + std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get); + let built = rayon::ThreadPoolBuilder::new() + .num_threads(rayon_pool_size(parallelism, RAYON_THREADS_PER_CORE)) + .build_global(); + let configured = built.is_ok(); + debug_assert!( + configured, + "rayon's global pool was built before it was configured: {built:?}", + ); + }); +} + +/// `threads_per_core × parallelism`, kept between [`MIN_RAYON_THREADS`] +/// and [`MAX_RAYON_THREADS`]. See [`RAYON_THREADS_PER_CORE`] for the +/// multiplier. The link phase is dominated by clonefile / +/// hardlink syscalls that block the calling thread on the kernel's +/// metadata journal, not by CPU work, so oversubscribing CPUs gives +/// more in-flight syscalls and a higher effective throughput. +/// Empirically sweeping 4-200 threads on a 1352-package warm install +/// on macOS APFS, 2× was the knee — fewer threads underutilize the +/// journal, way more (100+) loses to context switching and per-thread +/// fixed costs (`user` time scales linearly past 50 without any +/// wall-time payoff). That knee holds up to the ceiling below. Past 16 +/// threads, 2× no longer pays off. +/// +/// **Floor of 4 threads is intentional.** A 1-2-CPU CI runner left +/// at `2 × parallelism` would be capped to 2-4 rayon threads, and +/// at that point we go back to the original "one rayon thread is +/// blocked on a `clonefile` while the next fully-ready snapshot +/// can't even start" pattern that the 2× tuning is trying to +/// avoid. The kernel metadata journal is the bottleneck even on +/// small hosts, so a small intentional oversubscription +/// (`max(4, 2 × parallelism)`) is a better trade than respecting the +/// quota literally. +/// +/// **Ceiling of 16 threads.** Past 16, the extra workers add system +/// time without shortening the install. A warm-install sweep (nuxt, next, nitro +/// fixtures) found no machine where 2× beat 16 threads: a 32-vCPU +/// Linux runner kept its wall time and halved its system time, a +/// 16-vCPU Windows runner got 10% faster, and a 10-core M1 Max, the +/// one host where 2× beat 8 threads, was unchanged at 16 +/// (pnpm/tasks#51). A ceiling of 8 cut Linux system time further and +/// sped up Windows, but cost that Mac 19%. +#[must_use] +pub fn rayon_pool_size(parallelism: usize, threads_per_core: usize) -> usize { + parallelism.saturating_mul(threads_per_core).clamp(MIN_RAYON_THREADS, MAX_RAYON_THREADS) +} + +/// Two threads per core, except on Windows, where the sweeps mostly +/// favoured one (pnpm/tasks#52). Warm frozen installs were 4-5% faster +/// at 1× on 4- and 8-vCPU runners. A fresh install of the 1352-package +/// benchmark fixture took 3.9 s at 1× and 4.5 s at 2× on a 4-vCPU +/// runner, and was even on an 8-vCPU one. A 16-vCPU runner was fastest +/// at 4 threads, below what this multiplier gives it. +pub const RAYON_THREADS_PER_CORE: usize = if cfg!(windows) { 1 } else { 2 }; +pub const MIN_RAYON_THREADS: usize = 4; +pub const MAX_RAYON_THREADS: usize = 16; + +#[cfg(test)] +mod tests; diff --git a/pnpm/crates/package-manager/src/rayon_pool/tests.rs b/pnpm/crates/package-manager/src/rayon_pool/tests.rs new file mode 100644 index 0000000000..a777331239 --- /dev/null +++ b/pnpm/crates/package-manager/src/rayon_pool/tests.rs @@ -0,0 +1,36 @@ +use super::{configure_rayon_pool, rayon_pool_size}; + +#[test] +fn rayon_pool_is_the_scaled_parallelism_between_4_and_16_threads() { + let parallelism = [1, 2, 3, 4, 8, 9, 32, 256]; + assert_eq!(parallelism.map(|cores| rayon_pool_size(cores, 2)), [4, 4, 6, 8, 16, 16, 16, 16]); + assert_eq!(parallelism.map(|cores| rayon_pool_size(cores, 1)), [4, 4, 4, 4, 8, 9, 16, 16]); +} + +#[test] +fn configure_rayon_pool_is_idempotent() { + const CHILD: &str = "PNPM_RAYON_POOL_TEST_CHILD"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "rayon_pool::tests::configure_rayon_pool_is_idempotent"]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + return; + } + + configure_rayon_pool(); + configure_rayon_pool(); + let current_threads = rayon::current_num_threads(); + let parallelism = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get); + let override_threads = std::env::var("RAYON_NUM_THREADS") + .ok() + .and_then(|value| value.parse::().ok()); + let expected = match override_threads { + Some(0) => parallelism, + Some(threads) => threads, + None => rayon_pool_size(parallelism, super::RAYON_THREADS_PER_CORE), + }; + assert_eq!(current_threads, expected); +}