refactor: centralize CPU core detection using cgroup-aware utility
Release / create-release (push) Successful in 2m26s
Release / build-linux-x86_64 (push) Successful in 8m13s
Release / build-macos-arm64 (push) Successful in 1m43s
ci.yml / build (pull_request) Canceled after 1h29m17s

Introduce `obisys::effective_parallelism()` to read Linux cgroup v1/v2 CPU quotas from sysfs, preventing thread pool oversubscription in containerized environments. Replace direct `std::thread::available_parallelism()` calls across `obikindex` and `obikmer` with this centralized function. Bump `obikmer` version to 1.1.41.
This commit is contained in:
Eric Coissac
2026-08-11 12:23:05 +02:00
parent 2e7cfc4368
commit 5f95e866f8
8 changed files with 97 additions and 19 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
name: CI pname: CI
on: on:
pull_request: pull_request:
+1 -1
View File
@@ -1715,7 +1715,7 @@ dependencies = [
[[package]] [[package]]
name = "obikmer" name = "obikmer"
version = "1.1.40" version = "1.1.41"
dependencies = [ dependencies = [
"clap", "clap",
"csv", "csv",
+2 -6
View File
@@ -79,9 +79,7 @@ pub fn build() -> NumaSetup {
} }
// UMA fallback: single synthetic node, all cores, no pool, no pinning. // UMA fallback: single synthetic node, all cores, no pool, no pinning.
let n_cores = std::thread::available_parallelism() let n_cores = obisys::effective_parallelism();
.map(|n| n.get())
.unwrap_or(1);
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],
@@ -91,9 +89,7 @@ pub fn build() -> NumaSetup {
#[cfg(not(feature = "numa"))] #[cfg(not(feature = "numa"))]
pub fn build() -> NumaSetup { pub fn build() -> NumaSetup {
let n_cores = std::thread::available_parallelism() let n_cores = obisys::effective_parallelism();
.map(|n| n.get())
.unwrap_or(1);
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],
+1 -1
View File
@@ -350,7 +350,7 @@ impl KmerIndex {
// every single variant — fine at the scale of a handful of test // every single variant — fine at the scale of a handful of test
// k-mers, but with billions of lookups against a real index this // k-mers, but with billions of lookups against a real index this
// manifested as ~90% system time, observed in practice. ─────────── // manifested as ~90% system time, observed in practice. ───────────
let n_workers = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4); let n_workers = obisys::effective_parallelism();
let capacity = 256; let capacity = 256;
// Throttling is not optional once a `Flat` stage is in the pipeline // Throttling is not optional once a `Flat` stage is in the pipeline
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "obikmer" name = "obikmer"
version = "1.1.40" version = "1.1.41"
edition = "2024" edition = "2024"
[[bin]] [[bin]]
+1 -3
View File
@@ -38,9 +38,7 @@ pub struct CommonArgs {
#[arg( #[arg(
short = 'T', short = 'T',
long, long,
default_value_t = std::thread::available_parallelism() default_value_t = obisys::effective_parallelism()
.map(|n| n.get())
.unwrap_or(1)
)] )]
pub threads: usize, pub threads: usize,
+1 -3
View File
@@ -70,9 +70,7 @@ pub struct QueryArgs {
#[arg( #[arg(
short = 'T', short = 'T',
long, long,
default_value_t = std::thread::available_parallelism() default_value_t = obisys::effective_parallelism()
.map(|n| n.get())
.unwrap_or(1)
)] )]
pub threads: usize, pub threads: usize,
+89 -3
View File
@@ -202,6 +202,94 @@ fn cgroup_v1_available() -> Option<u64> {
Some(limit.saturating_sub(used)) Some(limit.saturating_sub(used))
} }
// ── CPU parallelism query ────────────────────────────────────────────────────
/// Returns the number of cores this process can actually use concurrently.
///
/// `std::thread::available_parallelism()` reads CPU affinity
/// (`sched_getaffinity`), not the container's CPU quota — a Docker/cgroup
/// container commonly reports the *host's* full core count this way while
/// actually being throttled (via `cpu.max`/`cpu.cfs_quota_us`) to a fraction
/// of a core. Sizing a thread/worker pool off the unthrottled count causes
/// severe oversubscription: dozens of threads contending for a sliver of
/// real CPU time, which can look indistinguishable from a hang for minutes
/// or hours (observed in CI). On Linux, this reads the cgroup CPU quota
/// first and returns `min(cgroup_quota, host_parallelism)` when a finite
/// quota is found; falls back to `available_parallelism()` otherwise (same
/// convention as [`available_memory_bytes`]).
pub fn effective_parallelism() -> usize {
let host = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
#[cfg(target_os = "linux")]
{
if let Some(quota) = cgroup_v2_cpu_quota() {
let effective = quota.clamp(1, host);
tracing::debug!(host, quota, effective, source = "cgroup v2", "effective_parallelism");
return effective;
}
if let Some(quota) = cgroup_v1_cpu_quota() {
let effective = quota.clamp(1, host);
tracing::debug!(host, quota, effective, source = "cgroup v1", "effective_parallelism");
return effective;
}
}
tracing::debug!(host, effective = host, source = "available_parallelism (no cgroup quota found)", "effective_parallelism");
host
}
/// cgroup v2 (unified hierarchy): reads `cpu.max` ("<quota> <period>", or
/// "max <period>" when unlimited) for the current process's cgroup, rounded
/// up to whole cores. Returns `None` if unlimited or on any parse error.
#[cfg(target_os = "linux")]
fn cgroup_v2_cpu_quota() -> Option<usize> {
let cgroup = std::fs::read_to_string("/proc/self/cgroup").ok()?;
let rel = cgroup
.lines()
.find(|l| l.starts_with("0::"))?
.strip_prefix("0::")?
.trim();
let base = format!("/sys/fs/cgroup{rel}");
let raw = std::fs::read_to_string(format!("{base}/cpu.max")).ok()?;
let mut parts = raw.split_whitespace();
let quota_str = parts.next()?;
let period: f64 = parts.next()?.parse().ok()?;
if quota_str == "max" {
return None; // unlimited
}
let quota: f64 = quota_str.parse().ok()?;
Some((quota / period).ceil().max(1.0) as usize)
}
/// cgroup v1 (cpu subsystem): reads `cpu.cfs_quota_us`/`cpu.cfs_period_us`,
/// rounded up to whole cores. Returns `None` if unlimited (quota <= 0) or on
/// any parse error.
#[cfg(target_os = "linux")]
fn cgroup_v1_cpu_quota() -> Option<usize> {
let cgroup = std::fs::read_to_string("/proc/self/cgroup").ok()?;
let path = cgroup
.lines()
.find(|l| l.contains(":cpu:") || l.contains(":cpu,cpuacct:"))?
.split(':')
.nth(2)?;
let base = format!("/sys/fs/cgroup/cpu{path}");
let quota: i64 = std::fs::read_to_string(format!("{base}/cpu.cfs_quota_us"))
.ok()?
.trim()
.parse()
.ok()?;
if quota <= 0 {
return None; // unlimited
}
let period: i64 = std::fs::read_to_string(format!("{base}/cpu.cfs_period_us"))
.ok()?
.trim()
.parse()
.ok()?;
if period <= 0 {
return None;
}
Some(((quota as f64) / (period as f64)).ceil().max(1.0) as usize)
}
// ── raw helpers ─────────────────────────────────────────────────────────────── // ── raw helpers ───────────────────────────────────────────────────────────────
fn get_rusage() -> rusage { fn get_rusage() -> rusage {
@@ -654,9 +742,7 @@ impl fmt::Display for Reporter {
return Ok(()); return Ok(());
} }
let n_cores = std::thread::available_parallelism() let n_cores = effective_parallelism();
.map(|n| n.get())
.unwrap_or(1);
// column widths // column widths
let nw = self let nw = self