Compare commits

...
2 Commits
Author SHA1 Message Date
coissac e280b6d0a3 Merge pull request 'Implement NUMA topology detection and CPU budget capping' (#74) from push-tnkxqmwztnmm into main
Reviewed-on: #74
2026-09-11 05:23:42 +00:00
Eric Coissac 98dba1802d Implement NUMA topology detection and CPU budget capping
Release / create-release (push) Successful in 2m30s
ci.yml / build (pull_request) Successful in 3m50s
Release / build-linux-x86_64 (push) Successful in 8m23s
Release / build-macos-arm64 (push) Successful in 1m57s
This change introduces logic to detect NUMA topology, calculate per-node worker limits based on available CPU cores, and cap resource allocation to ensure worker counts respect the physical capacity of each node.
2026-09-11 07:22:35 +02:00
4 changed files with 76 additions and 37 deletions
+1 -1
View File
@@ -1480,7 +1480,7 @@ dependencies = [
[[package]] [[package]]
name = "obikmer" name = "obikmer"
version = "1.3.2" version = "1.3.3"
dependencies = [ dependencies = [
"clap", "clap",
"csv", "csv",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "obikmer" name = "obikmer"
version = "1.3.2" version = "1.3.3"
edition = "2024" edition = "2024"
[[bin]] [[bin]]
+19 -13
View File
@@ -41,9 +41,10 @@ struct NodeConfig {
/// growth always targets a specific node rather than whichever dormant /// growth always targets a specific node rather than whichever dormant
/// worker happens to wake up first on a shared channel. Growth (both the /// worker happens to wake up first on a shared channel. Growth (both the
/// initial count and each subsequent step) is expressed as a fraction of /// initial count and each subsequent step) is expressed as a fraction of
/// `workers_per_node`, applied identically to every node, so the pace of /// each node's own worker cap, applied per node, so the pace of ramp-up
/// ramp-up depends on node size rather than node count — a single-NUMA-node /// depends on that node's size rather than the node count — a
/// (UMA) machine ramps just as fast as an 8-node one. /// single-NUMA-node (UMA) machine ramps just as fast as an 8-node one, and a
/// `--cpu-max`-emptied node simply never ramps.
/// ///
/// # Termination /// # Termination
/// ///
@@ -63,22 +64,27 @@ impl PartitionRunner {
} }
/// Detect topology and build. Always succeeds. /// Detect topology and build. Always succeeds.
///
/// Each node's worker count is its own (`--cpu-max`-budgeted) CPU count,
/// not a single value shared across nodes — `build()` can leave some
/// nodes with fewer cores than others (or none at all) once the process
/// CPU budget is smaller than the raw hardware topology, and spawning a
/// uniform worker count per node regardless would silently exceed that
/// budget on the emptied-out nodes.
pub fn new() -> Self { pub fn new() -> Self {
let ns = build(); let ns = build();
let wpn = ns.workers_per_node();
debug!( debug!(
"PartitionRunner: {} node(s) × {} worker(s)/node max", "PartitionRunner: {} node(s), {:?} core(s)/node",
ns.pools.len(), ns.pools.len(),
wpn, ns.cpus_per_node.iter().map(Vec::len).collect::<Vec<_>>(),
); );
let nodes = ns let nodes = ns
.pools .pools
.into_iter() .into_iter()
.zip(ns.cpus_per_node) .zip(ns.cpus_per_node)
.map(|(pool, cpu_ids)| NodeConfig { .map(|(pool, cpu_ids)| {
pool, let max_workers = cpu_ids.len();
cpu_ids, NodeConfig { pool, cpu_ids, max_workers }
max_workers: wpn,
}) })
.collect(); .collect();
Self { nodes } Self { nodes }
@@ -113,9 +119,9 @@ impl PartitionRunner {
/// Run `f(i)` for every index in `order`. /// Run `f(i)` for every index in `order`.
/// ///
/// Workers are pre-spawned dormant and activated adaptively, per node: /// Workers are pre-spawned dormant and activated adaptively, per node:
/// `(workers_per_node / INITIAL_DIVISOR).max(1)` are woken immediately on /// `(node's max_workers / INITIAL_DIVISOR).max(1)` are woken immediately
/// every node, then `(workers_per_node / GROWTH_DIVISOR).max(1)` more per /// on every node, then `(node's max_workers / GROWTH_DIVISOR).max(1)`
/// node each time the check below fires. A timer thread fires that check /// more per node each time the check below fires. A timer thread fires that check
/// every `TIMER_SECS` seconds; each completed partition resets that timer /// every `TIMER_SECS` seconds; each completed partition resets that timer
/// (forcing an immediate check) and also triggers its own inline check. A /// (forcing an immediate check) and also triggers its own inline check. A
/// growth step happens whenever CPU efficiency grows by at least /// growth step happens whenever CPU efficiency grows by at least
+55 -22
View File
@@ -19,22 +19,22 @@ pub struct NumaSetup {
pub cpus_per_node: Vec<Vec<usize>>, pub cpus_per_node: Vec<Vec<usize>>,
} }
impl NumaSetup {
/// Maximum worker slots per node (one per physical core in the node).
pub fn workers_per_node(&self) -> usize {
self.cpus_per_node
.first()
.map(|c| c.len().max(1))
.unwrap_or(1)
}
}
/// Detect NUMA topology and build per-node Rayon pools. /// Detect NUMA topology and build per-node Rayon pools.
/// Always succeeds: falls back to a single synthetic UMA node on failure. /// Always succeeds: falls back to a single synthetic UMA node on failure.
///
/// Every node's CPU list — and therefore its Rayon pool's thread count — is
/// capped against [`crate::cpu_budget`] (the process-wide ceiling set by
/// `--cpu-max`, or the cgroup/host default when unset) via
/// [`cap_to_budget`]. Sizing the pool itself, not just the outer worker
/// count layered on top in [`super::runner::PartitionRunner`], matters
/// because callers query `rayon::current_num_threads()` from *inside* a
/// pool-installed closure to size further internal parallelism — that call
/// only sees the requested budget if the pool itself was built that small.
#[cfg(feature = "numa")] #[cfg(feature = "numa")]
pub fn build() -> NumaSetup { pub fn build() -> NumaSetup {
let budget = crate::cpu_budget();
if let Ok(topology) = Topology::new() { if let Ok(topology) = Topology::new() {
let nodes: Vec<Vec<usize>> = topology let mut nodes: Vec<Vec<usize>> = topology
.objects_with_type(ObjectType::NUMANode) .objects_with_type(ObjectType::NUMANode)
.filter_map(|obj| obj.cpuset()) .filter_map(|obj| obj.cpuset())
.map(|cpuset| { .map(|cpuset| {
@@ -47,15 +47,23 @@ pub fn build() -> NumaSetup {
.collect(); .collect();
if nodes.len() > 1 { if nodes.len() > 1 {
cap_to_budget(&mut nodes, budget);
if let Some(pools) = nodes if let Some(pools) = nodes
.iter() .iter()
.map(|cpus| build_pool(cpus).map(|p| Some(Arc::new(p)))) .map(|cpus| {
if cpus.is_empty() {
Some(None)
} else {
build_pool(cpus).map(|p| Some(Arc::new(p)))
}
})
.collect::<Option<Vec<_>>>() .collect::<Option<Vec<_>>>()
{ {
debug!( debug!(
"NUMA topology: {} node(s), {} core(s)/node", "NUMA topology: {} node(s), {} core(s)/node, budget {}",
nodes.len(), nodes.len(),
nodes.first().map_or(0, |v| v.len()), nodes.first().map_or(0, |v| v.len()),
budget,
); );
return NumaSetup { return NumaSetup {
pools, pools,
@@ -65,8 +73,17 @@ pub fn build() -> NumaSetup {
} }
} }
// UMA fallback: single synthetic node, all cores, no pool, no pinning. // UMA fallback: single synthetic node, budget-capped cores, no pool, no pinning.
let n_cores = crate::effective_parallelism(); debug!("UMA: single synthetic node, {} core(s)", budget);
NumaSetup {
pools: vec![None],
cpus_per_node: vec![(0..budget).collect()],
}
}
#[cfg(not(feature = "numa"))]
pub fn build() -> NumaSetup {
let n_cores = crate::cpu_budget();
debug!("UMA: single synthetic node, {} core(s)", n_cores); debug!("UMA: single synthetic node, {} core(s)", n_cores);
NumaSetup { NumaSetup {
pools: vec![None], pools: vec![None],
@@ -74,13 +91,29 @@ pub fn build() -> NumaSetup {
} }
} }
#[cfg(not(feature = "numa"))] /// Trims each NUMA node's CPU list, in place, so the total across all nodes
pub fn build() -> NumaSetup { /// never exceeds `budget` — floor-split evenly across nodes. No-op when the
let n_cores = crate::effective_parallelism(); /// topology already fits within `budget`. When `budget` is smaller than the
debug!("UMA: single synthetic node, {} core(s)", n_cores); /// number of nodes, the trailing nodes are emptied entirely (one core each
NumaSetup { /// to as many leading nodes as `budget` allows) rather than every node
pools: vec![None], /// keeping a token core that would collectively blow the budget.
cpus_per_node: vec![(0..n_cores).collect()], #[cfg(feature = "numa")]
fn cap_to_budget(nodes: &mut [Vec<usize>], budget: usize) {
let total: usize = nodes.iter().map(Vec::len).sum();
if budget >= total || nodes.is_empty() {
return;
}
let per_node = (budget / nodes.len()).max(1);
for cpus in nodes.iter_mut() {
cpus.truncate(per_node);
}
let mut used = 0;
for cpus in nodes.iter_mut() {
if used >= budget {
cpus.clear();
} else {
used += cpus.len();
}
} }
} }