1use anyhow::Result;
10use hwlocality::{object::types::ObjectType, Topology};
11use std::collections::HashSet;
12
13#[derive(Debug, Clone)]
15pub struct NumaNode {
16 pub node_id: usize,
18 pub cpus: Vec<usize>,
20 pub memory_gb: f64,
22}
23
24#[derive(Debug, Clone)]
26pub struct NumaTopology {
27 pub num_nodes: usize,
29 pub physical_cores: usize,
31 pub logical_cpus: usize,
33 pub nodes: Vec<NumaNode>,
35 pub is_uma: bool,
37}
38
39impl NumaTopology {
40 pub fn detect() -> Result<Self> {
42 tracing::debug!("Detecting NUMA topology via hwlocality...");
43
44 let topology = Topology::new()?;
45
46 let numa_nodes: Vec<_> = topology.objects_with_type(ObjectType::NUMANode).collect();
48
49 let num_nodes = numa_nodes.len().max(1); let is_uma = num_nodes == 1;
51
52 tracing::info!("Detected {} NUMA node(s)", num_nodes);
53
54 let nodes: Vec<NumaNode> = if numa_nodes.is_empty() {
56 vec![NumaNode {
58 node_id: 0,
59 cpus: (0..num_cpus::get()).collect(),
60 memory_gb: 0.0,
61 }]
62 } else {
63 numa_nodes
64 .iter()
65 .filter_map(|node| {
66 let node_id = node.os_index()?;
67
68 let cpuset = node.cpuset()?;
70 let cpus: Vec<usize> = (0..topology.objects_with_type(ObjectType::PU).count())
71 .filter(|&cpu_id| cpuset.is_set(cpu_id))
72 .collect();
73
74 Some(NumaNode {
75 node_id,
76 cpus,
77 memory_gb: 0.0, })
79 })
80 .collect()
81 };
82
83 let physical_cores = num_cpus::get_physical();
84 let logical_cpus = num_cpus::get();
85
86 Ok(Self {
87 num_nodes,
88 physical_cores,
89 logical_cpus,
90 nodes,
91 is_uma,
92 })
93 }
94
95 pub fn should_enable_numa_pinning(&self) -> bool {
97 self.num_nodes > 1
98 }
99
100 pub fn deployment_type(&self) -> &str {
102 if self.is_uma {
103 "UMA (single NUMA node - cloud VM or workstation)"
104 } else {
105 "NUMA (multi-socket system or large cloud VM)"
106 }
107 }
108
109 pub fn cpus_for_node(&self, node_id: usize) -> Option<&[usize]> {
111 self.nodes
112 .iter()
113 .find(|n| n.node_id == node_id)
114 .map(|n| n.cpus.as_slice())
115 }
116}
117
118#[allow(dead_code)] fn detect_numa_nodes() -> Result<usize> {
124 tracing::trace!("detect_numa_nodes called");
125
126 #[cfg(target_os = "linux")]
127 {
128 let node_path = std::path::Path::new("/sys/devices/system/node");
130 if node_path.exists() {
131 let mut numa_nodes = Vec::new();
132
133 for entry in std::fs::read_dir(node_path)? {
134 let entry = entry?;
135 let name = entry.file_name();
136 let name_str = name.to_string_lossy();
137
138 if name_str.starts_with("node") && name_str[4..].chars().all(|c| c.is_ascii_digit())
139 {
140 if let Ok(node_id) = name_str[4..].parse::<usize>() {
141 numa_nodes.push(node_id);
142 }
143 }
144 }
145
146 if !numa_nodes.is_empty() {
147 return Ok(numa_nodes.len());
148 }
149 }
150
151 if let Ok(cpuinfo) = std::fs::read_to_string("/proc/cpuinfo") {
153 let mut physical_ids = HashSet::new();
154
155 for line in cpuinfo.lines() {
156 if line.starts_with("physical id") {
157 if let Some(id_str) = line.split(':').nth(1) {
158 if let Ok(id) = id_str.trim().parse::<usize>() {
159 physical_ids.insert(id);
160 }
161 }
162 }
163 }
164
165 if !physical_ids.is_empty() {
166 return Ok(physical_ids.len());
167 }
168 }
169 }
170
171 tracing::debug!("Could not detect NUMA topology, assuming UMA");
173 Ok(1)
174}
175
176#[allow(dead_code)] fn detect_numa_topology_details() -> Result<Vec<NumaNode>> {
179 #[cfg(target_os = "linux")]
180 {
181 let node_path = std::path::Path::new("/sys/devices/system/node");
182 if !node_path.exists() {
183 anyhow::bail!("NUMA topology not available");
184 }
185
186 let mut numa_nodes = Vec::new();
187
188 for entry in std::fs::read_dir(node_path)? {
189 let entry = entry?;
190 let name = entry.file_name();
191 let name_str = name.to_string_lossy();
192
193 if name_str.starts_with("node") && name_str[4..].chars().all(|c| c.is_ascii_digit()) {
194 if let Ok(node_id) = name_str[4..].parse::<usize>() {
195 let node_dir = entry.path();
196
197 let mut cpus = Vec::new();
199 let cpulist_path = node_dir.join("cpulist");
200 if let Ok(cpulist) = std::fs::read_to_string(&cpulist_path) {
201 for range in cpulist.trim().split(',') {
202 if range.contains('-') {
203 let parts: Vec<&str> = range.split('-').collect();
204 if parts.len() == 2 {
205 if let (Ok(start), Ok(end)) =
206 (parts[0].parse::<usize>(), parts[1].parse::<usize>())
207 {
208 for cpu in start..=end {
209 cpus.push(cpu);
210 }
211 }
212 }
213 } else if let Ok(cpu) = range.parse::<usize>() {
214 cpus.push(cpu);
215 }
216 }
217 }
218
219 let mut memory_gb = 0.0;
221 let meminfo_path = node_dir.join("meminfo");
222 if let Ok(meminfo) = std::fs::read_to_string(&meminfo_path) {
223 for line in meminfo.lines() {
224 if line.contains("MemTotal:") {
225 let parts: Vec<&str> = line.split_whitespace().collect();
226 if parts.len() >= 4 {
227 if let Ok(kb) = parts[3].parse::<f64>() {
228 memory_gb = kb / 1024.0 / 1024.0;
229 }
230 }
231 break;
232 }
233 }
234 }
235
236 numa_nodes.push(NumaNode {
237 node_id,
238 cpus,
239 memory_gb,
240 });
241 }
242 }
243 }
244
245 if numa_nodes.is_empty() {
246 anyhow::bail!("No NUMA nodes detected");
247 }
248
249 numa_nodes.sort_by_key(|n| n.node_id);
250 Ok(numa_nodes)
251 }
252
253 #[cfg(not(target_os = "linux"))]
254 {
255 anyhow::bail!("NUMA detection only supported on Linux");
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 fn init_tracing() {
264 use tracing_subscriber::{fmt, EnvFilter};
265 let _ = fmt()
266 .with_env_filter(EnvFilter::from_default_env())
267 .try_init();
268 }
269
270 #[test]
271 fn test_detect_topology() {
272 init_tracing();
273 if let Ok(topology) = NumaTopology::detect() {
274 println!("NUMA topology: {:?}", topology);
275 assert!(topology.num_nodes >= 1);
276 assert!(topology.physical_cores >= 1);
277 assert!(topology.logical_cpus >= topology.physical_cores);
278 }
279 }
280}