use anyhow::{Context, Result, bail};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct LlcInfo {
cpus: Vec<usize>,
numa_node: usize,
cache_size_kb: Option<u64>,
cores: BTreeMap<usize, Vec<usize>>,
}
impl LlcInfo {
pub fn cpus(&self) -> &[usize] {
&self.cpus
}
pub fn numa_node(&self) -> usize {
self.numa_node
}
pub fn cache_size_kb(&self) -> Option<u64> {
self.cache_size_kb
}
pub fn cores(&self) -> &BTreeMap<usize, Vec<usize>> {
&self.cores
}
pub fn num_cores(&self) -> usize {
if self.cores.is_empty() {
self.cpus.len()
} else {
self.cores.len()
}
}
}
#[derive(Debug, Clone)]
pub struct TestTopology {
cpus: Vec<usize>,
llcs: Vec<LlcInfo>,
numa_nodes: BTreeSet<usize>,
}
pub fn parse_cpu_list(s: &str) -> Result<Vec<usize>> {
let mut cpus = Vec::new();
for part in s.trim().split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
if let Some((lo, hi)) = part.split_once('-') {
let lo: usize = lo.parse()?;
let hi: usize = hi.parse()?;
cpus.extend(lo..=hi);
} else {
cpus.push(part.parse()?);
}
}
cpus.sort();
Ok(cpus)
}
pub fn parse_cpu_list_lenient(s: &str) -> Vec<usize> {
let mut cpus = Vec::new();
for part in s.trim().split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
if let Some((lo, hi)) = part.split_once('-') {
if let (Ok(lo), Ok(hi)) = (lo.parse::<usize>(), hi.parse::<usize>()) {
cpus.extend(lo..=hi);
}
} else if let Ok(cpu) = part.parse::<usize>() {
cpus.push(cpu);
}
}
cpus
}
fn find_llc_index(cpu: usize) -> Result<usize> {
let cache_dir = format!("/sys/devices/system/cpu/cpu{cpu}/cache");
let mut max_level = 0usize;
let mut llc_index = 0usize;
for entry in fs::read_dir(&cache_dir).context("read cache dir")? {
let entry = entry?;
let name = entry.file_name();
let name = name.to_string_lossy();
if !name.starts_with("index") {
continue;
}
let level_path = entry.path().join("level");
if let Ok(level_str) = fs::read_to_string(&level_path)
&& let Ok(level) = level_str.trim().parse::<usize>()
&& level > max_level
{
max_level = level;
llc_index = name
.strip_prefix("index")
.unwrap_or("0")
.parse()
.unwrap_or(0);
}
}
Ok(llc_index)
}
fn read_llc_id(cpu: usize) -> Result<usize> {
let llc_index = find_llc_index(cpu)?;
let id_path = format!("/sys/devices/system/cpu/cpu{cpu}/cache/index{llc_index}/id");
let id_str = fs::read_to_string(&id_path).unwrap_or_else(|_| llc_index.to_string());
Ok(id_str.trim().parse().unwrap_or(0))
}
fn read_numa_node(cpu: usize) -> Result<usize> {
let node_dir = format!("/sys/devices/system/cpu/cpu{cpu}");
for entry in fs::read_dir(&node_dir)? {
let entry = entry?;
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with("node")
&& let Some(id_str) = name.strip_prefix("node")
&& let Ok(id) = id_str.parse::<usize>()
{
return Ok(id);
}
}
Ok(0)
}
fn read_llc_cache_size(cpu: usize) -> Option<u64> {
let llc_index = find_llc_index(cpu).ok()?;
let size_path = format!("/sys/devices/system/cpu/cpu{cpu}/cache/index{llc_index}/size");
let size_str = fs::read_to_string(&size_path).ok()?;
parse_cache_size(size_str.trim())
}
fn parse_cache_size(s: &str) -> Option<u64> {
let s = s.trim();
if let Some(kb) = s.strip_suffix('K') {
kb.parse().ok()
} else if let Some(mb) = s.strip_suffix('M') {
mb.parse::<u64>().ok().map(|v| v * 1024)
} else {
s.parse::<u64>().ok().map(|v| v / 1024)
}
}
fn read_core_id(cpu: usize) -> Option<usize> {
let path = format!("/sys/devices/system/cpu/cpu{cpu}/topology/core_id");
fs::read_to_string(&path)
.ok()
.and_then(|s| s.trim().parse().ok())
}
impl TestTopology {
pub fn from_system() -> Result<Self> {
let online_str =
fs::read_to_string("/sys/devices/system/cpu/online").context("read online cpus")?;
let online_cpus = parse_cpu_list(&online_str)?;
if online_cpus.is_empty() {
bail!("no online CPUs found");
}
let mut cpus = BTreeSet::new();
let mut llc_map: BTreeMap<usize, LlcInfo> = BTreeMap::new();
let mut numa_nodes = BTreeSet::new();
let mut llc_cache_sizes: BTreeMap<usize, Option<u64>> = BTreeMap::new();
for &cpu_id in &online_cpus {
if !Path::new(&format!("/sys/devices/system/cpu/cpu{cpu_id}")).exists() {
continue;
}
cpus.insert(cpu_id);
let llc_id = read_llc_id(cpu_id).unwrap_or(0);
let node_id = read_numa_node(cpu_id).unwrap_or(0);
let core_id = read_core_id(cpu_id);
numa_nodes.insert(node_id);
llc_cache_sizes
.entry(llc_id)
.or_insert_with(|| read_llc_cache_size(cpu_id));
llc_map
.entry(llc_id)
.and_modify(|info| {
info.cpus.push(cpu_id);
if let Some(cid) = core_id {
info.cores.entry(cid).or_default().push(cpu_id);
}
})
.or_insert_with(|| {
let mut cores = BTreeMap::new();
if let Some(cid) = core_id {
cores.insert(cid, vec![cpu_id]);
}
LlcInfo {
cpus: vec![cpu_id],
numa_node: node_id,
cache_size_kb: llc_cache_sizes.get(&llc_id).copied().flatten(),
cores,
}
});
}
for info in llc_map.values_mut() {
info.cpus.sort();
for siblings in info.cores.values_mut() {
siblings.sort();
}
}
Ok(Self {
cpus: cpus.into_iter().collect(),
llcs: llc_map.into_values().collect(),
numa_nodes,
})
}
pub fn total_cpus(&self) -> usize {
self.cpus.len()
}
pub fn num_llcs(&self) -> usize {
self.llcs.len()
}
pub fn num_numa_nodes(&self) -> usize {
self.numa_nodes.len()
}
pub fn llcs(&self) -> &[LlcInfo] {
&self.llcs
}
pub fn all_cpus(&self) -> &[usize] {
&self.cpus
}
pub fn all_cpuset(&self) -> BTreeSet<usize> {
self.cpus.iter().copied().collect()
}
pub fn usable_cpus(&self) -> &[usize] {
if self.cpus.len() > 2 {
&self.cpus[..self.cpus.len() - 1]
} else {
&self.cpus
}
}
pub fn usable_cpuset(&self) -> BTreeSet<usize> {
self.usable_cpus().iter().copied().collect()
}
pub fn cpus_in_llc(&self, idx: usize) -> &[usize] {
&self.llcs[idx].cpus
}
pub fn llc_aligned_cpuset(&self, idx: usize) -> BTreeSet<usize> {
self.llcs[idx].cpus.iter().copied().collect()
}
pub fn split_by_llc(&self) -> Vec<BTreeSet<usize>> {
self.llcs
.iter()
.map(|l| l.cpus.iter().copied().collect())
.collect()
}
pub fn overlapping_cpusets(&self, n: usize, overlap_frac: f64) -> Vec<BTreeSet<usize>> {
let total = self.cpus.len();
if n == 0 || total == 0 {
return vec![];
}
let base = total / n;
let overlap = ((base as f64) * overlap_frac).ceil() as usize;
let stride = if base > overlap { base - overlap } else { 1 };
(0..n)
.map(|i| {
let start = (i * stride) % total;
(0..base.max(1))
.map(|j| self.cpus[(start + j) % total])
.collect()
})
.collect()
}
pub fn cpuset_string(cpus: &BTreeSet<usize>) -> String {
if cpus.is_empty() {
return String::new();
}
let sorted: Vec<usize> = cpus.iter().copied().collect();
let mut ranges = Vec::new();
let (mut start, mut end) = (sorted[0], sorted[0]);
for &cpu in &sorted[1..] {
if cpu == end + 1 {
end = cpu;
} else {
ranges.push(if start == end {
format!("{start}")
} else {
format!("{start}-{end}")
});
start = cpu;
end = cpu;
}
}
ranges.push(if start == end {
format!("{start}")
} else {
format!("{start}-{end}")
});
ranges.join(",")
}
pub fn from_spec(sockets: u32, cores: u32, threads: u32) -> Self {
let total = (sockets * cores * threads) as usize;
let cpus_per_socket = (cores * threads) as usize;
let cpus: Vec<usize> = (0..total).collect();
let llcs: Vec<LlcInfo> = (0..sockets as usize)
.map(|s| {
let start = s * cpus_per_socket;
let end = start + cpus_per_socket;
let mut core_map = BTreeMap::new();
for c in 0..cores as usize {
let base = start + c * threads as usize;
let siblings: Vec<usize> = (base..base + threads as usize).collect();
core_map.insert(c, siblings);
}
LlcInfo {
cpus: (start..end).collect(),
numa_node: s,
cache_size_kb: None,
cores: core_map,
}
})
.collect();
let numa_nodes = (0..sockets as usize).collect();
Self {
cpus,
llcs,
numa_nodes,
}
}
#[cfg(test)]
pub fn synthetic(num_cpus: usize, num_llcs: usize) -> Self {
let cpus: Vec<usize> = (0..num_cpus).collect();
let per_llc = num_cpus / num_llcs;
let llcs: Vec<LlcInfo> = (0..num_llcs)
.map(|i| {
let start = i * per_llc;
let end = if i == num_llcs - 1 {
num_cpus
} else {
(i + 1) * per_llc
};
LlcInfo {
cpus: (start..end).collect(),
numa_node: i,
cache_size_kb: None,
cores: BTreeMap::new(),
}
})
.collect();
let numa_nodes = (0..num_llcs).collect();
Self {
cpus,
llcs,
numa_nodes,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cpuset_string_empty() {
assert_eq!(TestTopology::cpuset_string(&BTreeSet::new()), "");
}
#[test]
fn cpuset_string_single() {
assert_eq!(TestTopology::cpuset_string(&[3].into_iter().collect()), "3");
}
#[test]
fn cpuset_string_range() {
assert_eq!(
TestTopology::cpuset_string(&[0, 1, 2, 3].into_iter().collect()),
"0-3"
);
}
#[test]
fn cpuset_string_gaps() {
assert_eq!(
TestTopology::cpuset_string(&[0, 1, 3, 5, 6, 7].into_iter().collect()),
"0-1,3,5-7"
);
}
#[test]
fn synthetic_topology() {
let t = TestTopology::synthetic(8, 2);
assert_eq!(t.total_cpus(), 8);
assert_eq!(t.num_llcs(), 2);
assert_eq!(t.cpus_in_llc(0), &[0, 1, 2, 3]);
assert_eq!(t.cpus_in_llc(1), &[4, 5, 6, 7]);
}
#[test]
fn overlapping_cpusets_basic() {
let t = TestTopology::synthetic(8, 1);
let sets = t.overlapping_cpusets(2, 0.5);
assert_eq!(sets.len(), 2);
for s in &sets {
assert_eq!(s.len(), 4);
}
let overlap: BTreeSet<usize> = sets[0].intersection(&sets[1]).copied().collect();
assert!(!overlap.is_empty());
}
#[test]
fn overlapping_cpusets_no_overlap() {
let t = TestTopology::synthetic(8, 1);
let sets = t.overlapping_cpusets(2, 0.0);
assert_eq!(sets.len(), 2);
let overlap: BTreeSet<usize> = sets[0].intersection(&sets[1]).copied().collect();
assert!(overlap.is_empty());
}
#[test]
fn split_by_llc() {
let t = TestTopology::synthetic(8, 2);
let splits = t.split_by_llc();
assert_eq!(splits.len(), 2);
assert_eq!(splits[0], [0, 1, 2, 3].into_iter().collect());
assert_eq!(splits[1], [4, 5, 6, 7].into_iter().collect());
}
#[test]
fn llc_aligned_cpuset() {
let t = TestTopology::synthetic(8, 2);
assert_eq!(t.llc_aligned_cpuset(0), [0, 1, 2, 3].into_iter().collect());
assert_eq!(t.llc_aligned_cpuset(1), [4, 5, 6, 7].into_iter().collect());
}
#[test]
fn from_spec_single_socket() {
let t = TestTopology::from_spec(1, 4, 2);
assert_eq!(t.total_cpus(), 8);
assert_eq!(t.num_llcs(), 1);
assert_eq!(t.num_numa_nodes(), 1);
assert_eq!(t.all_cpus(), &[0, 1, 2, 3, 4, 5, 6, 7]);
assert_eq!(t.cpus_in_llc(0), &[0, 1, 2, 3, 4, 5, 6, 7]);
}
#[test]
fn from_spec_multi_socket() {
let t = TestTopology::from_spec(2, 4, 2);
assert_eq!(t.total_cpus(), 16);
assert_eq!(t.num_llcs(), 2);
assert_eq!(t.num_numa_nodes(), 2);
assert_eq!(t.cpus_in_llc(0), &[0, 1, 2, 3, 4, 5, 6, 7]);
assert_eq!(t.cpus_in_llc(1), &[8, 9, 10, 11, 12, 13, 14, 15]);
}
#[test]
fn from_spec_no_smt() {
let t = TestTopology::from_spec(2, 2, 1);
assert_eq!(t.total_cpus(), 4);
assert_eq!(t.num_llcs(), 2);
assert_eq!(t.cpus_in_llc(0), &[0, 1]);
assert_eq!(t.cpus_in_llc(1), &[2, 3]);
}
#[test]
fn from_spec_minimal() {
let t = TestTopology::from_spec(1, 1, 1);
assert_eq!(t.total_cpus(), 1);
assert_eq!(t.num_llcs(), 1);
assert_eq!(t.all_cpus(), &[0]);
}
#[test]
fn overlapping_cpusets_zero_n() {
let t = TestTopology::synthetic(8, 1);
assert!(t.overlapping_cpusets(0, 0.5).is_empty());
}
#[test]
fn synthetic_single_llc() {
let t = TestTopology::synthetic(4, 1);
assert_eq!(t.num_llcs(), 1);
assert_eq!(t.total_cpus(), 4);
assert_eq!(t.num_numa_nodes(), 1);
assert_eq!(t.all_cpus(), &[0, 1, 2, 3]);
}
#[test]
fn synthetic_many_llcs() {
let t = TestTopology::synthetic(16, 4);
assert_eq!(t.num_llcs(), 4);
for i in 0..4 {
assert_eq!(t.cpus_in_llc(i).len(), 4);
}
}
#[test]
fn cpuset_string_two_ranges() {
assert_eq!(
TestTopology::cpuset_string(&[0, 1, 2, 5, 6, 7].into_iter().collect()),
"0-2,5-7"
);
}
#[test]
fn cpuset_string_all_isolated() {
assert_eq!(
TestTopology::cpuset_string(&[1, 3, 5].into_iter().collect()),
"1,3,5"
);
}
#[test]
fn cpuset_string_large_range() {
let cpus: BTreeSet<usize> = (0..128).collect();
assert_eq!(TestTopology::cpuset_string(&cpus), "0-127");
}
#[test]
fn overlapping_cpusets_single_set() {
let t = TestTopology::synthetic(8, 1);
let sets = t.overlapping_cpusets(1, 0.5);
assert_eq!(sets.len(), 1);
assert_eq!(sets[0].len(), 8);
}
#[test]
fn split_by_llc_single() {
let t = TestTopology::synthetic(4, 1);
let splits = t.split_by_llc();
assert_eq!(splits.len(), 1);
assert_eq!(splits[0].len(), 4);
}
#[test]
fn split_by_llc_two_socket_regression() {
let t = TestTopology::from_spec(2, 4, 1);
assert_eq!(t.total_cpus(), 8);
assert_eq!(t.num_llcs(), 2);
let splits = t.split_by_llc();
assert_eq!(splits.len(), 2, "2-socket topology must produce 2 LLC sets");
let overlap: BTreeSet<usize> = splits[0].intersection(&splits[1]).copied().collect();
assert!(
overlap.is_empty(),
"LLC sets must be disjoint: overlap={overlap:?}"
);
let union: BTreeSet<usize> = splits[0].union(&splits[1]).copied().collect();
assert_eq!(union, t.all_cpuset(), "LLC sets must cover all CPUs");
assert_eq!(splits[0].len(), 4);
assert_eq!(splits[1].len(), 4);
assert_eq!(splits[0], [0, 1, 2, 3].into_iter().collect());
assert_eq!(splits[1], [4, 5, 6, 7].into_iter().collect());
}
#[test]
fn usable_cpus_reserves_last() {
let t = TestTopology::synthetic(8, 2);
assert_eq!(t.usable_cpus().len(), 7);
assert!(!t.usable_cpus().contains(&7));
}
#[test]
fn usable_cpus_small_no_reserve() {
let t = TestTopology::synthetic(2, 1);
assert_eq!(t.usable_cpus().len(), 2);
}
#[test]
fn usable_cpus_single_cpu() {
let t = TestTopology::synthetic(1, 1);
assert_eq!(t.usable_cpus().len(), 1);
}
#[test]
fn parse_cpu_list_simple() {
assert_eq!(parse_cpu_list("0,1,2,3").unwrap(), vec![0, 1, 2, 3]);
}
#[test]
fn parse_cpu_list_range() {
assert_eq!(parse_cpu_list("0-3").unwrap(), vec![0, 1, 2, 3]);
}
#[test]
fn parse_cpu_list_mixed() {
assert_eq!(
parse_cpu_list("0-2,5,7-9").unwrap(),
vec![0, 1, 2, 5, 7, 8, 9]
);
}
#[test]
fn parse_cpu_list_empty() {
assert!(parse_cpu_list("").unwrap().is_empty());
}
#[test]
fn parse_cpu_list_whitespace() {
assert_eq!(parse_cpu_list(" 0 , 1 , 2 ").unwrap(), vec![0, 1, 2]);
}
#[test]
fn from_spec_large() {
let t = TestTopology::from_spec(4, 8, 2);
assert_eq!(t.total_cpus(), 64);
assert_eq!(t.num_llcs(), 4);
assert_eq!(t.num_numa_nodes(), 4);
}
#[test]
fn llc_info_accessors() {
let t = TestTopology::synthetic(8, 2);
let llcs = t.llcs();
assert_eq!(llcs.len(), 2);
assert_eq!(llcs[0].cpus(), &[0, 1, 2, 3]);
assert_eq!(llcs[0].numa_node(), 0);
assert_eq!(llcs[1].cpus(), &[4, 5, 6, 7]);
assert_eq!(llcs[1].numa_node(), 1);
}
#[test]
fn from_spec_cores_populated() {
let t = TestTopology::from_spec(2, 4, 2);
let llc0 = &t.llcs()[0];
assert_eq!(llc0.num_cores(), 4);
assert_eq!(llc0.cores().len(), 4);
assert_eq!(llc0.cores()[&0], vec![0, 1]);
assert_eq!(llc0.cores()[&1], vec![2, 3]);
assert_eq!(llc0.cores()[&2], vec![4, 5]);
assert_eq!(llc0.cores()[&3], vec![6, 7]);
let llc1 = &t.llcs()[1];
assert_eq!(llc1.cores()[&0], vec![8, 9]);
}
#[test]
fn from_spec_no_smt_cores() {
let t = TestTopology::from_spec(1, 4, 1);
let llc = &t.llcs()[0];
assert_eq!(llc.num_cores(), 4);
assert_eq!(llc.cores()[&0], vec![0]);
assert_eq!(llc.cores()[&3], vec![3]);
}
#[test]
fn parse_cache_size_formats() {
assert_eq!(parse_cache_size("32768K"), Some(32768));
assert_eq!(parse_cache_size("32M"), Some(32768));
assert_eq!(parse_cache_size("65536"), Some(64));
}
#[test]
fn num_cores_from_cores_map() {
let llc = LlcInfo {
cpus: vec![0, 1, 2, 3],
numa_node: 0,
cache_size_kb: None,
cores: BTreeMap::from([(0, vec![0, 1]), (1, vec![2, 3])]),
};
assert_eq!(llc.num_cores(), 2);
}
#[test]
fn num_cores_fallback_to_cpus() {
let llc = LlcInfo {
cpus: vec![0, 1, 2, 3],
numa_node: 0,
cache_size_kb: None,
cores: BTreeMap::new(),
};
assert_eq!(llc.num_cores(), 4);
}
#[test]
fn parse_cpu_list_lenient_simple() {
assert_eq!(parse_cpu_list_lenient("0,1,2,3"), vec![0, 1, 2, 3]);
}
#[test]
fn parse_cpu_list_lenient_range() {
assert_eq!(parse_cpu_list_lenient("0-3"), vec![0, 1, 2, 3]);
}
#[test]
fn parse_cpu_list_lenient_mixed() {
assert_eq!(
parse_cpu_list_lenient("0-2,5,7-9"),
vec![0, 1, 2, 5, 7, 8, 9]
);
}
#[test]
fn parse_cpu_list_lenient_empty() {
assert!(parse_cpu_list_lenient("").is_empty());
}
#[test]
fn parse_cpu_list_lenient_skips_garbage() {
assert_eq!(parse_cpu_list_lenient("0,abc,2,xyz-3,4"), vec![0, 2, 4]);
}
#[test]
fn parse_cpu_list_lenient_whitespace() {
assert_eq!(parse_cpu_list_lenient(" 0 , 1 , 2 "), vec![0, 1, 2]);
}
#[test]
fn cache_size_bare_number() {
assert_eq!(parse_cache_size("1024"), Some(1));
}
#[test]
fn cache_size_empty_string() {
assert_eq!(parse_cache_size(""), None);
}
#[test]
fn cache_size_whitespace_only() {
assert_eq!(parse_cache_size(" "), None);
}
proptest::proptest! {
#[test]
fn prop_parse_cpu_list_never_panics(s in "\\PC{0,30}") {
let _ = parse_cpu_list(&s);
}
#[test]
fn prop_parse_cpu_list_single_cpu(cpu in 0usize..256) {
let result = parse_cpu_list(&cpu.to_string()).unwrap();
assert_eq!(result, vec![cpu]);
}
#[test]
fn prop_parse_cpu_list_range_sorted(lo in 0usize..128, span in 1usize..64) {
let hi = lo + span;
let result = parse_cpu_list(&format!("{lo}-{hi}")).unwrap();
assert_eq!(result.len(), span + 1);
assert_eq!(*result.first().unwrap(), lo);
assert_eq!(*result.last().unwrap(), hi);
for w in result.windows(2) {
assert!(w[0] <= w[1]);
}
}
#[test]
fn prop_parse_cpu_list_lenient_never_panics(s in "\\PC{0,30}") {
let _ = parse_cpu_list_lenient(&s);
}
#[test]
fn prop_parse_cpu_list_lenient_superset_of_strict(
lo in 0usize..64,
hi in 64usize..128,
) {
let s = format!("{lo}-{hi}");
let strict = parse_cpu_list(&s).unwrap();
let lenient = parse_cpu_list_lenient(&s);
assert_eq!(strict, lenient);
}
#[test]
fn prop_parse_cpu_list_roundtrip(
cpus in proptest::collection::btree_set(0usize..256, 1..16),
) {
let s: String = cpus.iter().map(|c| c.to_string()).collect::<Vec<_>>().join(",");
let parsed = parse_cpu_list(&s).unwrap();
let roundtrip: std::collections::BTreeSet<usize> = parsed.into_iter().collect();
assert_eq!(cpus, roundtrip);
}
}
}