#[cfg(any(test, target_os = "linux"))]
use std::collections::BTreeSet;
use std::fmt;
use std::sync::Arc;
use thiserror::Error;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CpuId(usize);
impl CpuId {
pub const fn new(id: usize) -> Self {
Self(id)
}
pub const fn as_usize(self) -> usize {
self.0
}
}
impl fmt::Display for CpuId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct NumaNodeId(usize);
impl NumaNodeId {
pub const fn new(id: usize) -> Self {
Self(id)
}
pub const fn as_usize(self) -> usize {
self.0
}
}
impl fmt::Display for NumaNodeId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
pub enum CpuSetError {
#[error("CPU set is empty")]
Empty,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct CpuSet {
cpus: Arc<[CpuId]>,
}
impl CpuSet {
pub(crate) fn singleton(cpu: CpuId) -> Self {
Self {
cpus: Arc::from([cpu]),
}
}
pub fn new(cpus: impl IntoIterator<Item = CpuId>) -> Result<Self, CpuSetError> {
let mut cpus: Vec<_> = cpus.into_iter().collect();
cpus.sort_unstable();
cpus.dedup();
if cpus.is_empty() {
return Err(CpuSetError::Empty);
}
Ok(Self { cpus: cpus.into() })
}
pub fn len(&self) -> usize {
self.cpus.len()
}
pub fn is_empty(&self) -> bool {
self.cpus.is_empty()
}
pub fn as_slice(&self) -> &[CpuId] {
&self.cpus
}
pub fn as_usize_vec(&self) -> Vec<usize> {
self.cpus.iter().map(|cpu| cpu.as_usize()).collect()
}
pub fn contains(&self, cpu: CpuId) -> bool {
self.cpus.binary_search(&cpu).is_ok()
}
pub(crate) fn overlaps(&self, other: &Self) -> bool {
let (mut left, mut right) = (0, 0);
while left < self.len() && right < other.len() {
match self.cpus[left].cmp(&other.cpus[right]) {
std::cmp::Ordering::Less => left += 1,
std::cmp::Ordering::Greater => right += 1,
std::cmp::Ordering::Equal => return true,
}
}
false
}
#[cfg(any(test, target_os = "linux"))]
fn intersection(&self, other: &Self) -> Option<Self> {
let mut intersection = Vec::with_capacity(self.len().min(other.len()));
let (mut left, mut right) = (0, 0);
while left < self.len() && right < other.len() {
match self.cpus[left].cmp(&other.cpus[right]) {
std::cmp::Ordering::Less => left += 1,
std::cmp::Ordering::Greater => right += 1,
std::cmp::Ordering::Equal => {
intersection.push(self.cpus[left]);
left += 1;
right += 1;
}
}
}
(!intersection.is_empty()).then_some(Self {
cpus: intersection.into(),
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CpuNode {
id: NumaNodeId,
cpus: CpuSet,
}
impl CpuNode {
pub fn new(id: NumaNodeId, cpus: CpuSet) -> Self {
Self { id, cpus }
}
pub fn id(&self) -> NumaNodeId {
self.id
}
pub fn cpus(&self) -> &CpuSet {
&self.cpus
}
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum CpuTopologyError {
#[error("the process-allowed CPU set is empty")]
EmptyAllowedCpuSet,
#[error("the process CPU affinity mask is unavailable")]
AffinityUnavailable,
#[error("invalid Linux CPU list {list:?}: {reason}")]
InvalidCpuList {
list: String,
reason: &'static str,
},
#[error("NUMA node {node} was discovered more than once")]
DuplicateNode {
node: NumaNodeId,
},
#[error("NUMA nodes {first} and {second} overlap on CPUs {cpus:?}")]
OverlappingNodes {
first: NumaNodeId,
second: NumaNodeId,
cpus: CpuSet,
},
}
#[cfg(any(test, target_os = "linux"))]
pub(crate) trait TopologySource {
fn allowed_cpus(&self) -> Result<CpuSet, CpuTopologyError>;
fn numa_node_cpu_lists(&self) -> Result<Option<Vec<(NumaNodeId, String)>>, CpuTopologyError>;
}
#[cfg(any(test, target_os = "linux"))]
pub(crate) fn discover_from(source: &impl TopologySource) -> Result<CpuTopology, CpuTopologyError> {
let allowed_cpus = source.allowed_cpus()?;
let Some(node_cpu_lists) = source.numa_node_cpu_lists()? else {
return Ok(CpuTopology::all_allowed(allowed_cpus));
};
let nodes = node_cpu_lists
.into_iter()
.map(|(id, cpus)| parse_linux_cpu_list(&cpus).map(|cpus| (id, cpus)))
.collect::<Result<Vec<_>, _>>()?;
CpuTopology::from_discovered(allowed_cpus, nodes)
}
#[cfg(any(test, target_os = "linux"))]
pub(crate) fn parse_linux_cpu_list(input: &str) -> Result<CpuSet, CpuTopologyError> {
const MAX_PARSED_CPUS: usize = 1 << 20;
let invalid = |reason| CpuTopologyError::InvalidCpuList {
list: input.to_owned(),
reason,
};
let input = input.trim();
if input.is_empty() {
return Err(invalid("list is empty"));
}
let mut cpus = Vec::new();
for component in input.split(',') {
let component = component.trim();
if component.is_empty() {
return Err(invalid("empty list component"));
}
if let Some((start, end)) = component.split_once('-') {
if end.contains('-') {
return Err(invalid("range contains more than one separator"));
}
let start = start
.parse::<usize>()
.map_err(|_| invalid("range start is not a CPU number"))?;
let end = end
.parse::<usize>()
.map_err(|_| invalid("range end is not a CPU number"))?;
let span = end
.checked_sub(start)
.and_then(|distance| distance.checked_add(1))
.ok_or_else(|| invalid("range is reversed or overflows"))?;
if span > MAX_PARSED_CPUS || cpus.len().saturating_add(span) > MAX_PARSED_CPUS {
return Err(invalid("list contains too many CPUs"));
}
cpus.extend((start..=end).map(CpuId::new));
} else {
let cpu = component
.parse::<usize>()
.map_err(|_| invalid("component is not a CPU number"))?;
cpus.push(CpuId::new(cpu));
if cpus.len() > MAX_PARSED_CPUS {
return Err(invalid("list contains too many CPUs"));
}
}
}
CpuSet::new(cpus).map_err(|_| invalid("list is empty"))
}
pub fn discover_cpu_topology() -> Result<CpuTopology, CpuTopologyError> {
#[cfg(target_os = "linux")]
{
discover_from(&LinuxTopologySource)
}
#[cfg(not(target_os = "linux"))]
{
let allowed = crate::process_cpu_affinity().unwrap_or_else(|| {
CpuSet::new((0..crate::available_parallelism()).map(CpuId::new))
.unwrap_or_else(|_| CpuSet::singleton(CpuId::new(0)))
});
Ok(CpuTopology::all_allowed(allowed))
}
}
#[cfg(target_os = "linux")]
struct LinuxTopologySource;
#[cfg(target_os = "linux")]
impl TopologySource for LinuxTopologySource {
fn allowed_cpus(&self) -> Result<CpuSet, CpuTopologyError> {
crate::process_cpu_affinity().ok_or(CpuTopologyError::AffinityUnavailable)
}
fn numa_node_cpu_lists(&self) -> Result<Option<Vec<(NumaNodeId, String)>>, CpuTopologyError> {
let entries = match std::fs::read_dir("/sys/devices/system/node") {
Ok(entries) => entries,
Err(_) => return Ok(None),
};
let mut nodes = Vec::new();
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(_) => return Ok(None),
};
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
let Some(id) = name.strip_prefix("node") else {
continue;
};
if id.is_empty() || !id.bytes().all(|byte| byte.is_ascii_digit()) {
continue;
}
let Ok(id) = id.parse::<usize>() else {
continue;
};
let cpus = match std::fs::read_to_string(entry.path().join("cpulist")) {
Ok(cpus) => cpus,
Err(_) => return Ok(None),
};
nodes.push((NumaNodeId::new(id), cpus));
}
if nodes.is_empty() {
Ok(None)
} else {
nodes.sort_unstable_by_key(|(id, _)| *id);
Ok(Some(nodes))
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CpuTopology {
allowed_cpus: CpuSet,
nodes: Vec<CpuNode>,
}
impl CpuTopology {
pub fn all_allowed(allowed_cpus: CpuSet) -> Self {
Self {
allowed_cpus,
nodes: Vec::new(),
}
}
#[cfg(any(test, target_os = "linux"))]
pub(crate) fn from_discovered(
allowed_cpus: CpuSet,
nodes: impl IntoIterator<Item = (NumaNodeId, CpuSet)>,
) -> Result<Self, CpuTopologyError> {
if allowed_cpus.is_empty() {
return Err(CpuTopologyError::EmptyAllowedCpuSet);
}
let mut usable = Vec::new();
let mut seen_node_ids = BTreeSet::new();
for (id, discovered_cpus) in nodes {
if !seen_node_ids.insert(id) {
return Err(CpuTopologyError::DuplicateNode { node: id });
}
if let Some(cpus) = discovered_cpus.intersection(&allowed_cpus) {
usable.push(CpuNode::new(id, cpus));
}
}
usable.sort_unstable_by_key(CpuNode::id);
for (index, left) in usable.iter().enumerate() {
for right in usable.iter().skip(index + 1) {
if let Some(cpus) = left.cpus.intersection(&right.cpus) {
return Err(CpuTopologyError::OverlappingNodes {
first: left.id,
second: right.id,
cpus,
});
}
}
}
Ok(Self {
allowed_cpus,
nodes: usable,
})
}
pub fn allowed_cpus(&self) -> &CpuSet {
&self.allowed_cpus
}
pub fn nodes(&self) -> &[CpuNode] {
&self.nodes
}
pub fn node_ids(&self) -> Vec<NumaNodeId> {
self.nodes.iter().map(CpuNode::id).collect()
}
pub fn node(&self, id: NumaNodeId) -> Option<&CpuNode> {
self.nodes
.binary_search_by_key(&id, CpuNode::id)
.ok()
.map(|index| &self.nodes[index])
}
pub fn has_numa_nodes(&self) -> bool {
!self.nodes.is_empty()
}
}
#[cfg(test)]
mod tests;