use std::collections::BTreeMap;
pub const DECODE_AFFINITY_ENV: &str = "ONNX_GENAI_CPU_DECODE_AFFINITY";
const ACCEPTED_MODES: &str = "`off`, `compact`, `node:<index>`, `numa-split`";
fn available_nodes_clause(topology: Option<&NumaTopology>) -> String {
match topology {
Some(topology) if !topology.nodes.is_empty() => {
let list = topology
.nodes
.keys()
.map(usize::to_string)
.collect::<Vec<_>>()
.join(", ");
format!("available NUMA nodes are [{list}]")
}
_ => "NUMA topology is unavailable on this host (single NUMA node or a \
platform without discoverable NUMA topology), so no node selector \
can be honored"
.to_string(),
}
}
fn invalid_selector_error(value: &str, topology: Option<&NumaTopology>) -> String {
format!(
"{DECODE_AFFINITY_ENV}=`{value}` is not a usable decode-affinity selector; \
accepted modes are {ACCEPTED_MODES}; {clause}",
clause = available_nodes_clause(topology),
)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DecodeAffinity {
Off,
Compact,
Node(usize),
NumaSplit,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NodeShard {
pub index: usize,
pub cpus: Vec<usize>,
pub workers: usize,
}
impl DecodeAffinity {
pub fn parse(raw: Option<&str>) -> std::result::Result<Self, String> {
let Some(raw) = raw else {
return Ok(Self::Off);
};
let trimmed = raw.trim();
match trimmed {
"" | "off" | "0" => Ok(Self::Off),
"compact" => Ok(Self::Compact),
"numa-split" => Ok(Self::NumaSplit),
other => {
if let Some(index) = other.strip_prefix("node:") {
index.trim().parse::<usize>().map(Self::Node).map_err(|_| {
format!(
"{DECODE_AFFINITY_ENV}=`{raw}` is not a valid NUMA node selector; \
expected `node:<index>` with a non-negative integer index"
)
})
} else {
Err(format!(
"{DECODE_AFFINITY_ENV}=`{raw}` is not a recognized affinity mode; \
expected `off`, `compact`, or `node:<index>`"
))
}
}
}
}
pub fn from_env() -> std::result::Result<Self, String> {
let raw = std::env::var(DECODE_AFFINITY_ENV).ok();
Self::resolve(raw.as_deref(), NumaTopology::detect().as_ref())
}
pub fn resolve(
raw: Option<&str>,
topology: Option<&NumaTopology>,
) -> std::result::Result<Self, String> {
match Self::parse(raw) {
Ok(Self::Node(index)) => {
if topology
.and_then(|topology| topology.cpus_for_node(index))
.is_some()
{
Ok(Self::Node(index))
} else {
Err(invalid_selector_error(&format!("node:{index}"), topology))
}
}
Ok(affinity) => Ok(affinity),
Err(_) => Err(invalid_selector_error(
raw.unwrap_or_default().trim(),
topology,
)),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct NumaTopology {
nodes: BTreeMap<usize, Vec<usize>>,
}
impl NumaTopology {
pub fn detect() -> Option<Self> {
#[cfg(target_os = "linux")]
{
Self::detect_linux()
}
#[cfg(target_os = "windows")]
{
Self::detect_windows()
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
None
}
}
#[cfg(target_os = "linux")]
fn detect_linux() -> Option<Self> {
let mut nodes = BTreeMap::new();
let entries = std::fs::read_dir("/sys/devices/system/node").ok()?;
for entry in entries.flatten() {
let file_name = entry.file_name();
let name = file_name.to_str()?;
let Some(index) = name.strip_prefix("node") else {
continue;
};
let Ok(index) = index.parse::<usize>() else {
continue;
};
let cpulist = entry.path().join("cpulist");
let Ok(contents) = std::fs::read_to_string(&cpulist) else {
continue;
};
let cpus = parse_cpu_list(&contents);
if !cpus.is_empty() {
nodes.insert(index, cpus);
}
}
(nodes.len() > 1).then_some(Self { nodes })
}
#[cfg(target_os = "windows")]
fn detect_windows() -> Option<Self> {
let nodes = windows_imp::numa_nodes()?;
let nodes: BTreeMap<usize, Vec<usize>> = nodes
.into_iter()
.filter(|(_, cpus)| !cpus.is_empty())
.collect();
(nodes.len() > 1).then_some(Self { nodes })
}
pub fn restrict_to_allowed(&self, allowed: Option<&[usize]>) -> Self {
let Some(allowed) = allowed else {
return self.clone();
};
let allowed: std::collections::BTreeSet<usize> = allowed.iter().copied().collect();
let nodes = self
.nodes
.iter()
.filter_map(|(&index, cpus)| {
let kept: Vec<usize> = cpus
.iter()
.copied()
.filter(|c| allowed.contains(c))
.collect();
(!kept.is_empty()).then_some((index, kept))
})
.collect();
Self { nodes }
}
pub fn node_count(&self) -> usize {
self.nodes.len()
}
pub fn cpus_for_node(&self, node: usize) -> Option<&[usize]> {
self.nodes.get(&node).map(Vec::as_slice)
}
pub fn cpus_for(
&self,
affinity: &DecodeAffinity,
worker_count: usize,
) -> std::result::Result<Option<Vec<usize>>, String> {
match affinity {
DecodeAffinity::Off => Ok(None),
DecodeAffinity::Node(index) => match self.cpus_for_node(*index) {
Some(cpus) => Ok(Some(cpus.to_vec())),
None => Err(format!(
"{DECODE_AFFINITY_ENV}=`node:{index}` names an unknown NUMA node; \
accepted modes are {ACCEPTED_MODES}; {clause}",
clause = available_nodes_clause(Some(self)),
)),
},
DecodeAffinity::Compact => {
let fitting = self.nodes.values().find(|cpus| cpus.len() >= worker_count);
let chosen = fitting.or_else(|| self.nodes.values().max_by_key(|cpus| cpus.len()));
Ok(chosen.map(|cpus| cpus.to_vec()))
}
DecodeAffinity::NumaSplit => {
Ok(None)
}
}
}
pub fn split_workers(&self, total_workers: usize) -> Option<Vec<NodeShard>> {
if total_workers == 0 || self.nodes.len() < 2 {
return None;
}
let node_count = self.nodes.len();
let base = total_workers / node_count;
let remainder = total_workers % node_count;
let mut shards = Vec::with_capacity(node_count);
for (position, (&index, cpus)) in self.nodes.iter().enumerate() {
let requested = base + usize::from(position < remainder);
let workers = requested.min(cpus.len());
if workers == 0 {
continue;
}
shards.push(NodeShard {
index,
cpus: cpus.clone(),
workers,
});
}
(shards.len() >= 2).then_some(shards)
}
}
#[cfg(any(target_os = "linux", test))]
fn parse_cpu_list(list: &str) -> Vec<usize> {
let mut cpus = Vec::new();
for part in list.trim().split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
if let Some((start, end)) = part.split_once('-') {
if let (Ok(start), Ok(end)) =
(start.trim().parse::<usize>(), end.trim().parse::<usize>())
{
cpus.extend(start..=end);
}
} else if let Ok(cpu) = part.parse::<usize>() {
cpus.push(cpu);
}
}
cpus
}
#[cfg(target_os = "linux")]
fn build_cpu_mask(cpu: usize) -> Option<Vec<libc::c_ulong>> {
let bits_per_word = 8 * std::mem::size_of::<libc::c_ulong>();
let word_index = cpu / bits_per_word;
let bit = cpu % bits_per_word;
let len = word_index.checked_add(1)?;
let mut mask = vec![0 as libc::c_ulong; len];
mask[word_index] = (1 as libc::c_ulong) << bit;
Some(mask)
}
#[cfg(target_os = "linux")]
pub fn pin_current_thread_to_cpu(cpu: usize) -> std::result::Result<(), String> {
let mask = build_cpu_mask(cpu)
.ok_or_else(|| format!("cpu index {cpu} is too large to build a CPU affinity mask"))?;
let byte_len = mask.len() * std::mem::size_of::<libc::c_ulong>();
let result =
unsafe { libc::sched_setaffinity(0, byte_len, mask.as_ptr() as *const libc::cpu_set_t) };
if result == 0 {
Ok(())
} else {
Err(format!(
"sched_setaffinity(cpu={cpu}) failed: {}",
std::io::Error::last_os_error()
))
}
}
#[cfg(target_os = "windows")]
pub fn pin_current_thread_to_cpu(cpu: usize) -> std::result::Result<(), String> {
windows_imp::pin_current_thread_to_cpu(cpu)
}
#[cfg(target_os = "macos")]
pub fn pin_current_thread_to_cpu(cpu: usize) -> std::result::Result<(), String> {
Err(format!(
"thread-to-core affinity is not supported on macOS; \
decode worker for cpu {cpu} runs unpinned (no-op)"
))
}
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
pub fn pin_current_thread_to_cpu(cpu: usize) -> std::result::Result<(), String> {
Err(format!(
"thread-to-core affinity is not implemented on this platform; \
decode worker for cpu {cpu} runs unpinned (no-op)"
))
}
pub const fn pinning_supported() -> bool {
cfg!(any(target_os = "linux", target_os = "windows"))
}
#[cfg(target_os = "linux")]
fn build_cpu_mask_multi(cpus: &[usize]) -> Option<Vec<libc::c_ulong>> {
let bits_per_word = 8 * std::mem::size_of::<libc::c_ulong>();
let max_cpu = *cpus.iter().max()?;
let len = (max_cpu / bits_per_word).checked_add(1)?;
let mut mask = vec![0 as libc::c_ulong; len];
for &cpu in cpus {
mask[cpu / bits_per_word] |= (1 as libc::c_ulong) << (cpu % bits_per_word);
}
Some(mask)
}
#[cfg(target_os = "linux")]
pub fn set_current_thread_affinity(cpus: &[usize]) -> std::result::Result<(), String> {
if cpus.is_empty() {
return Err("cannot set CPU affinity to an empty CPU set".to_string());
}
let mask = build_cpu_mask_multi(cpus)
.ok_or_else(|| "CPU indices are too large to build a CPU affinity mask".to_string())?;
let byte_len = mask.len() * std::mem::size_of::<libc::c_ulong>();
let result =
unsafe { libc::sched_setaffinity(0, byte_len, mask.as_ptr() as *const libc::cpu_set_t) };
if result == 0 {
Ok(())
} else {
Err(format!(
"sched_setaffinity(cpus={cpus:?}) failed: {}",
std::io::Error::last_os_error()
))
}
}
#[cfg(not(target_os = "linux"))]
pub fn set_current_thread_affinity(cpus: &[usize]) -> std::result::Result<(), String> {
let _ = cpus;
Err("process-wide CPU affinity masking is only implemented on Linux (no-op)".to_string())
}
pub fn explicit_decode_affinity_requested() -> bool {
std::env::var(DECODE_AFFINITY_ENV)
.ok()
.is_some_and(|value| !value.trim().is_empty())
}
fn choose_budget_cpus(
topology: Option<&NumaTopology>,
allowed: Option<&[usize]>,
count: usize,
) -> Option<Vec<usize>> {
if count == 0 {
return None;
}
let allowed_set: Option<std::collections::BTreeSet<usize>> =
allowed.map(|a| a.iter().copied().collect());
let mut selected: Vec<usize> = Vec::new();
if let Some(topology) = topology
&& let Ok(Some(node_cpus)) = topology.cpus_for(&DecodeAffinity::Compact, count)
{
selected = node_cpus;
}
if let Some(set) = &allowed_set {
selected.retain(|cpu| set.contains(cpu));
}
selected.sort_unstable();
selected.dedup();
selected.truncate(count);
if selected.len() < count && let Some(allowed) = allowed {
let mut extra: Vec<usize> = allowed
.iter()
.copied()
.filter(|cpu| !selected.contains(cpu))
.collect();
extra.sort_unstable();
for cpu in extra {
if selected.len() >= count {
break;
}
selected.push(cpu);
}
}
selected.sort_unstable();
(!selected.is_empty()).then_some(selected)
}
pub fn select_budget_cpus(count: usize) -> Option<Vec<usize>> {
let allowed = allowed_cpus();
let restricted = NumaTopology::detect().map(|t| t.restrict_to_allowed(allowed.as_deref()));
choose_budget_cpus(restricted.as_ref(), allowed.as_deref(), count)
}
pub fn allowed_cpus() -> Option<Vec<usize>> {
#[cfg(target_os = "linux")]
{
linux_allowed_cpus()
}
#[cfg(target_os = "windows")]
{
windows_imp::allowed_cpus()
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
None
}
}
#[cfg(target_os = "linux")]
fn linux_allowed_cpus() -> Option<Vec<usize>> {
let bits_per_word = 8 * std::mem::size_of::<libc::c_ulong>();
let mut words = 128 / bits_per_word.max(1);
words = words.max(16);
loop {
let mut mask = vec![0 as libc::c_ulong; words];
let byte_len = words * std::mem::size_of::<libc::c_ulong>();
let result = unsafe {
libc::sched_getaffinity(0, byte_len, mask.as_mut_ptr() as *mut libc::cpu_set_t)
};
if result == 0 {
let mut cpus = Vec::new();
for (word_index, &word) in mask.iter().enumerate() {
for bit in 0..bits_per_word {
if word & ((1 as libc::c_ulong) << bit) != 0 {
cpus.push(word_index * bits_per_word + bit);
}
}
}
return (!cpus.is_empty()).then_some(cpus);
}
let err = std::io::Error::last_os_error();
if err.raw_os_error() == Some(libc::EINVAL) {
words = words.checked_mul(2)?;
if words * bits_per_word > 1 << 20 {
return None;
}
continue;
}
return None;
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DecodePlan {
pub cpus: Option<Vec<usize>>,
pub log: Option<String>,
}
fn decide_affinity(
raw: Option<&str>,
restricted: Option<&NumaTopology>,
host_is_multinode: bool,
pinning_supported: bool,
) -> std::result::Result<(DecodeAffinity, Option<String>), String> {
let restricted_nodes = restricted.map(NumaTopology::node_count).unwrap_or(0);
let is_unset = raw.map(|value| value.trim().is_empty()).unwrap_or(true);
if is_unset {
if !pinning_supported {
return Ok((
DecodeAffinity::Off,
Some(format!(
"{DECODE_AFFINITY_ENV} unset and CPU pinning is not supported on this OS; \
decode pool left unpinned"
)),
));
}
if restricted_nodes >= 2 {
return Ok((
DecodeAffinity::Compact,
Some(format!(
"{DECODE_AFFINITY_ENV} unset and host has {restricted_nodes} usable NUMA \
nodes; auto-enabling `compact` (pin the decode pool to one node for \
bandwidth/barrier locality). Set {DECODE_AFFINITY_ENV}=off to opt out, or \
any explicit mode ({ACCEPTED_MODES}) to override"
)),
));
}
if host_is_multinode {
return Ok((
DecodeAffinity::Off,
Some(format!(
"{DECODE_AFFINITY_ENV} unset; host is multi-node but the process cpuset spans \
fewer than two NUMA nodes, so auto-enable declines and the decode pool is \
left unpinned (safe under container/taskset restriction)"
)),
));
}
return Ok((DecodeAffinity::Off, None));
}
match DecodeAffinity::resolve(raw, restricted)? {
DecodeAffinity::Off => Ok((DecodeAffinity::Off, None)),
affinity => Ok((affinity, None)),
}
}
pub fn plan_decode_affinity(worker_count: usize) -> std::result::Result<DecodePlan, String> {
let raw = std::env::var(DECODE_AFFINITY_ENV).ok();
let full = NumaTopology::detect();
let host_is_multinode = full.as_ref().map(|t| t.node_count() >= 2).unwrap_or(false);
let allowed = allowed_cpus();
let restricted = full
.as_ref()
.map(|t| t.restrict_to_allowed(allowed.as_deref()));
let usable = restricted.as_ref().filter(|t| t.node_count() >= 1);
let (affinity, log) = decide_affinity(
raw.as_deref(),
usable,
host_is_multinode,
pinning_supported(),
)?;
let cpus = match usable {
Some(topology) => topology
.cpus_for(&affinity, worker_count)?
.filter(|cpus| !cpus.is_empty()),
None => None,
};
Ok(DecodePlan { cpus, log })
}
#[cfg(target_os = "windows")]
mod windows_imp {
use std::mem::{size_of, zeroed};
use windows_sys::Win32::System::SystemInformation::{
GetLogicalProcessorInformationEx, RelationNumaNode, SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX,
};
use windows_sys::Win32::System::Threading::{
GetCurrentThread, GetThreadGroupAffinity, SetThreadGroupAffinity,
};
const GROUP_BITS: usize = 64;
fn cpus_from_mask(group: u16, mask: usize) -> Vec<usize> {
let base = group as usize * GROUP_BITS;
(0..GROUP_BITS)
.filter(|bit| mask & (1usize << bit) != 0)
.map(|bit| base + bit)
.collect()
}
pub(super) fn numa_nodes() -> Option<Vec<(usize, Vec<usize>)>> {
let mut len: u32 = 0;
unsafe {
GetLogicalProcessorInformationEx(RelationNumaNode, std::ptr::null_mut(), &mut len);
}
if len == 0 {
return None;
}
let mut buffer = vec![0u8; len as usize];
let ok = unsafe {
GetLogicalProcessorInformationEx(
RelationNumaNode,
buffer.as_mut_ptr() as *mut SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX,
&mut len,
)
};
if ok == 0 {
return None;
}
let mut nodes: Vec<(usize, Vec<usize>)> = Vec::new();
let mut offset = 0usize;
let end = len as usize;
while offset + size_of::<u32>() * 2 <= end {
let record = unsafe {
std::ptr::read_unaligned(
buffer.as_ptr().add(offset) as *const SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX
)
};
let size = record.Size as usize;
if size == 0 || offset + size > end {
break;
}
if record.Relationship == RelationNumaNode {
let numa = unsafe { record.Anonymous.NumaNode };
let node_index = numa.NodeNumber as usize;
let group_mask = unsafe { numa.Anonymous.GroupMask };
let cpus = cpus_from_mask(group_mask.Group, group_mask.Mask as usize);
if !cpus.is_empty() {
nodes.push((node_index, cpus));
}
}
offset += size;
}
Some(nodes)
}
pub(super) fn pin_current_thread_to_cpu(cpu: usize) -> std::result::Result<(), String> {
let group = (cpu / GROUP_BITS) as u16;
let bit = cpu % GROUP_BITS;
let mut affinity =
unsafe { zeroed::<windows_sys::Win32::System::SystemInformation::GROUP_AFFINITY>() };
affinity.Group = group;
affinity.Mask = 1usize << bit;
let ok =
unsafe { SetThreadGroupAffinity(GetCurrentThread(), &affinity, std::ptr::null_mut()) };
if ok != 0 {
Ok(())
} else {
Err(format!(
"SetThreadGroupAffinity(group={group}, cpu={cpu}) failed: {}",
std::io::Error::last_os_error()
))
}
}
pub(super) fn allowed_cpus() -> Option<Vec<usize>> {
let mut affinity =
unsafe { zeroed::<windows_sys::Win32::System::SystemInformation::GROUP_AFFINITY>() };
let ok = unsafe { GetThreadGroupAffinity(GetCurrentThread(), &mut affinity) };
if ok == 0 {
return None;
}
let cpus = cpus_from_mask(affinity.Group, affinity.Mask as usize);
(!cpus.is_empty()).then_some(cpus)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_affinity_modes() {
assert_eq!(DecodeAffinity::parse(None).unwrap(), DecodeAffinity::Off);
assert_eq!(
DecodeAffinity::parse(Some("off")).unwrap(),
DecodeAffinity::Off
);
assert_eq!(
DecodeAffinity::parse(Some("compact")).unwrap(),
DecodeAffinity::Compact
);
assert_eq!(
DecodeAffinity::parse(Some("node:1")).unwrap(),
DecodeAffinity::Node(1)
);
assert_eq!(
DecodeAffinity::parse(Some("numa-split")).unwrap(),
DecodeAffinity::NumaSplit
);
assert!(DecodeAffinity::parse(Some("node:x")).is_err());
assert!(DecodeAffinity::parse(Some("bogus")).is_err());
}
#[test]
fn parses_cpu_lists() {
assert_eq!(parse_cpu_list("0-3"), vec![0, 1, 2, 3]);
assert_eq!(parse_cpu_list("0-3,8,10-11"), vec![0, 1, 2, 3, 8, 10, 11]);
assert_eq!(parse_cpu_list(" 5 "), vec![5]);
assert!(parse_cpu_list("").is_empty());
}
#[test]
fn compact_picks_smallest_covering_node() {
let mut nodes = BTreeMap::new();
nodes.insert(0, (0..48).collect::<Vec<_>>());
nodes.insert(1, (48..96).collect::<Vec<_>>());
let topology = NumaTopology { nodes };
let cpus = topology
.cpus_for(&DecodeAffinity::Compact, 32)
.unwrap()
.unwrap();
assert_eq!(cpus.len(), 48);
assert_eq!(cpus[0], 0);
}
#[test]
fn node_selector_reports_unknown_node() {
let mut nodes = BTreeMap::new();
nodes.insert(0, vec![0, 1]);
nodes.insert(1, vec![2, 3]);
let topology = NumaTopology { nodes };
assert!(
topology
.cpus_for(&DecodeAffinity::Node(7), 2)
.unwrap_err()
.contains("unknown NUMA node")
);
let cpus = topology
.cpus_for(&DecodeAffinity::Node(1), 2)
.unwrap()
.unwrap();
assert_eq!(cpus, vec![2, 3]);
}
#[test]
fn resolve_reports_consistent_diagnostics_for_invalid_values() {
let mut nodes = BTreeMap::new();
nodes.insert(0, vec![0, 1]);
nodes.insert(2, vec![4, 5]);
let topology = NumaTopology { nodes };
for (raw, needle) in [
("bogus", "bogus"), ("node:x", "node:x"), ("node:9", "node:9"), ] {
let err = DecodeAffinity::resolve(Some(raw), Some(&topology)).unwrap_err();
assert!(
err.contains(needle),
"names rejected value `{needle}`: {err}"
);
assert!(
err.contains("`off`")
&& err.contains("`compact`")
&& err.contains("`node:<index>`"),
"lists all accepted modes: {err}"
);
assert!(
err.contains("available NUMA nodes are [0, 2]"),
"lists available nodes: {err}"
);
}
}
#[test]
fn resolve_reports_topology_unavailable_for_node_without_topology() {
let err = DecodeAffinity::resolve(Some("node:1"), None).unwrap_err();
assert!(err.contains("node:1"), "{err}");
assert!(
err.contains("`off`") && err.contains("`compact`") && err.contains("`node:<index>`"),
"{err}"
);
assert!(err.contains("NUMA topology is unavailable"), "{err}");
assert_eq!(
DecodeAffinity::resolve(Some("compact"), None).unwrap(),
DecodeAffinity::Compact
);
assert_eq!(
DecodeAffinity::resolve(None, None).unwrap(),
DecodeAffinity::Off
);
}
#[cfg(target_os = "linux")]
#[test]
fn build_cpu_mask_sizes_beyond_cpu_setsize_without_oob() {
let bits = 8 * std::mem::size_of::<libc::c_ulong>();
let mask = build_cpu_mask(3).unwrap();
assert_eq!(mask.len(), 1);
assert_eq!(mask[0], (1 as libc::c_ulong) << 3);
let cpu = 1024;
let mask = build_cpu_mask(cpu).unwrap();
let word = cpu / bits;
assert_eq!(mask.len(), word + 1);
assert!(
mask.len() * std::mem::size_of::<libc::c_ulong>()
> std::mem::size_of::<libc::cpu_set_t>(),
"cpu {cpu} must need a mask larger than a fixed cpu_set_t"
);
assert_eq!(mask[word], (1 as libc::c_ulong) << (cpu % bits));
assert!(mask[..word].iter().all(|&w| w == 0));
let cpu = 5000;
let mask = build_cpu_mask(cpu).unwrap();
assert_eq!(mask.len(), cpu / bits + 1);
assert_eq!(mask[cpu / bits], (1 as libc::c_ulong) << (cpu % bits));
}
#[test]
fn compact_prefers_smallest_index_not_fewest_cpus() {
let mut nodes = BTreeMap::new();
nodes.insert(0, (0..64).collect::<Vec<_>>());
nodes.insert(1, (64..96).collect::<Vec<_>>());
let topology = NumaTopology { nodes };
let cpus = topology
.cpus_for(&DecodeAffinity::Compact, 16)
.unwrap()
.unwrap();
assert_eq!(cpus[0], 0);
assert_eq!(cpus.len(), 64);
}
#[test]
fn split_workers_spreads_evenly_across_nodes() {
let mut nodes = BTreeMap::new();
nodes.insert(0, (0..48).collect::<Vec<_>>());
nodes.insert(1, (48..96).collect::<Vec<_>>());
let topology = NumaTopology { nodes };
let shards = topology.split_workers(32).unwrap();
assert_eq!(shards.len(), 2);
assert_eq!(shards[0].index, 0);
assert_eq!(shards[0].workers, 16);
assert_eq!(shards[0].cpus[0], 0);
assert_eq!(shards[1].index, 1);
assert_eq!(shards[1].workers, 16);
assert_eq!(shards[1].cpus[0], 48);
let odd = topology.split_workers(33).unwrap();
assert_eq!(odd[0].workers, 17);
assert_eq!(odd[1].workers, 16);
}
#[test]
fn split_workers_caps_workers_at_node_cpu_count() {
let mut nodes = BTreeMap::new();
nodes.insert(0, vec![0, 1]);
nodes.insert(1, vec![2, 3]);
let topology = NumaTopology { nodes };
let shards = topology.split_workers(8).unwrap();
assert_eq!(shards.len(), 2);
assert!(shards.iter().all(|shard| shard.workers == 2));
}
#[test]
fn split_workers_needs_two_populated_nodes() {
let mut nodes = BTreeMap::new();
nodes.insert(0, vec![0, 1]);
let topology = NumaTopology { nodes };
assert!(topology.split_workers(4).is_none());
assert!(topology.split_workers(0).is_none());
}
#[test]
fn numa_split_declines_when_topology_cannot_be_split() {
let mut single = BTreeMap::new();
single.insert(0, vec![0, 1, 2, 3]);
let single = NumaTopology { nodes: single };
assert!(
single.split_workers(8).is_none(),
"single-node host must decline the split"
);
let host = two_node_topology();
let restricted = host.restrict_to_allowed(Some(&[0, 1, 2]));
assert_eq!(restricted.node_count(), 1);
assert!(
restricted.split_workers(8).is_none(),
"cpuset confined to one node must decline the split"
);
assert!(host.split_workers(8).is_some());
}
fn two_node_topology() -> NumaTopology {
let mut nodes = BTreeMap::new();
nodes.insert(0, (0..8).collect::<Vec<_>>());
nodes.insert(1, (8..16).collect::<Vec<_>>());
NumaTopology { nodes }
}
#[test]
fn restrict_to_allowed_filters_cpus_and_drops_empty_nodes() {
let topology = two_node_topology();
assert_eq!(topology.restrict_to_allowed(None), topology);
let allowed = vec![1, 2, 9, 10];
let restricted = topology.restrict_to_allowed(Some(&allowed));
assert_eq!(restricted.node_count(), 2);
assert_eq!(restricted.cpus_for_node(0).unwrap(), &[1, 2]);
assert_eq!(restricted.cpus_for_node(1).unwrap(), &[9, 10]);
let allowed = vec![0, 1, 2];
let restricted = topology.restrict_to_allowed(Some(&allowed));
assert_eq!(restricted.node_count(), 1);
assert_eq!(restricted.cpus_for_node(0).unwrap(), &[0, 1, 2]);
assert!(restricted.cpus_for_node(1).is_none());
let restricted = topology.restrict_to_allowed(Some(&[999]));
assert_eq!(restricted.node_count(), 0);
}
#[test]
fn auto_enable_pins_compact_on_multi_node_host() {
let topology = two_node_topology();
let (affinity, log) = decide_affinity(None, Some(&topology), true, true).unwrap();
assert_eq!(affinity, DecodeAffinity::Compact);
let log = log.expect("auto-enable logs its decision");
assert!(log.contains("auto-enabling `compact`"), "{log}");
assert!(log.contains("off"), "mentions the opt-out: {log}");
}
#[test]
fn auto_enable_declines_on_single_node_host() {
let mut nodes = BTreeMap::new();
nodes.insert(0, (0..8).collect::<Vec<_>>());
let single = NumaTopology { nodes };
let (affinity, log) = decide_affinity(None, Some(&single), false, true).unwrap();
assert_eq!(affinity, DecodeAffinity::Off);
assert!(log.is_none(), "single-node auto-decision is quiet: {log:?}");
let (affinity, log) = decide_affinity(None, None, false, true).unwrap();
assert_eq!(affinity, DecodeAffinity::Off);
assert!(log.is_none());
}
#[test]
fn auto_enable_declines_when_cpuset_restricts_to_one_node() {
let mut nodes = BTreeMap::new();
nodes.insert(0, (0..4).collect::<Vec<_>>());
let restricted = NumaTopology { nodes };
let (affinity, log) = decide_affinity(None, Some(&restricted), true, true).unwrap();
assert_eq!(affinity, DecodeAffinity::Off);
let log = log.expect("cpuset decline logs its decision");
assert!(log.contains("cpuset"), "{log}");
assert!(log.contains("fewer than two"), "{log}");
}
#[test]
fn auto_enable_declines_when_pinning_unsupported() {
let topology = two_node_topology();
let (affinity, log) = decide_affinity(None, Some(&topology), true, false).unwrap();
assert_eq!(affinity, DecodeAffinity::Off);
let log = log.expect("unsupported-OS decision logs why");
assert!(log.contains("not supported on this OS"), "{log}");
}
#[test]
fn explicit_off_opts_out_without_auto_log() {
let topology = two_node_topology();
let (affinity, log) = decide_affinity(Some("off"), Some(&topology), true, true).unwrap();
assert_eq!(affinity, DecodeAffinity::Off);
assert!(log.is_none());
}
#[test]
fn explicit_modes_are_honored_and_validated_against_restricted_topology() {
let topology = two_node_topology();
let (affinity, log) =
decide_affinity(Some("compact"), Some(&topology), true, true).unwrap();
assert_eq!(affinity, DecodeAffinity::Compact);
assert!(log.is_none(), "explicit modes carry no auto note");
let (affinity, _) = decide_affinity(Some("node:1"), Some(&topology), true, true).unwrap();
assert_eq!(affinity, DecodeAffinity::Node(1));
let mut nodes = BTreeMap::new();
nodes.insert(0, (0..4).collect::<Vec<_>>());
let restricted = NumaTopology { nodes };
let err = decide_affinity(Some("node:1"), Some(&restricted), true, true).unwrap_err();
assert!(err.contains("node:1"), "{err}");
}
#[test]
fn pinning_supported_matches_target_os() {
assert_eq!(
pinning_supported(),
cfg!(any(target_os = "linux", target_os = "windows"))
);
}
#[test]
fn choose_budget_cpus_prefers_a_single_node() {
let topology = two_node_topology();
let allowed: Vec<usize> = (0..16).collect();
let chosen = choose_budget_cpus(Some(&topology), Some(&allowed), 4).unwrap();
assert_eq!(chosen, vec![0, 1, 2, 3]);
}
#[test]
fn choose_budget_cpus_tops_up_across_nodes_when_no_node_covers_count() {
let topology = two_node_topology();
let allowed: Vec<usize> = (0..16).collect();
let chosen = choose_budget_cpus(Some(&topology), Some(&allowed), 12).unwrap();
assert_eq!(chosen.len(), 12);
assert!(chosen.iter().all(|cpu| allowed.contains(cpu)));
}
#[test]
fn choose_budget_cpus_never_exceeds_allowed_set() {
let topology = two_node_topology();
let allowed = vec![8, 9, 10, 11];
let chosen = choose_budget_cpus(Some(&topology), Some(&allowed), 8).unwrap();
assert_eq!(chosen, vec![8, 9, 10, 11]);
}
#[test]
fn choose_budget_cpus_without_topology_uses_allowed_prefix() {
let allowed = vec![2, 3, 5, 7, 11];
let chosen = choose_budget_cpus(None, Some(&allowed), 3).unwrap();
assert_eq!(chosen, vec![2, 3, 5]);
}
#[test]
fn choose_budget_cpus_returns_none_without_any_usable_cpu() {
assert!(choose_budget_cpus(None, None, 4).is_none());
assert!(choose_budget_cpus(Some(&two_node_topology()), Some(&[0]), 0).is_none());
}
}