scirs2_core/memory/numa_allocator.rs
1//! NUMA-aware memory allocation strategies for high-performance computing.
2//!
3//! This module provides utilities for NUMA (Non-Uniform Memory Access) aware
4//! memory management, which can significantly improve performance on multi-socket
5//! systems by ensuring data is allocated close to the processor that will use it.
6//!
7//! On systems that do not expose NUMA topology (single-socket workstations, etc.)
8//! the module transparently falls back to treating the whole machine as a single
9//! NUMA node, so code using this module is portable without conditional
10//! compilation.
11//!
12//! # Architecture
13//!
14//! ```text
15//! NumaTopology ──► [NumaNode {id, cpus, memory}]
16//! │
17//! ▼
18//! NumaAwarePool<T>
19//! │
20//! ├── per-node free-block cache (Vec<Vec<T>>)
21//! └── NumaBuffer<T> (owns Vec<T> + records node_id)
22//! ```
23
24use crate::error::{CoreError, CoreResult, ErrorContext};
25
26// ---------------------------------------------------------------------------
27// NumaNode
28// ---------------------------------------------------------------------------
29
30/// Information about a single NUMA node.
31#[derive(Debug, Clone)]
32pub struct NumaNode {
33 /// Numeric NUMA node identifier (0-based).
34 pub id: usize,
35 /// Logical CPU IDs belonging to this node.
36 pub cpu_ids: Vec<usize>,
37 /// Total memory on this node in MiB (0 when unknown).
38 pub memory_mb: usize,
39 /// Free memory on this node in MiB (0 when unknown).
40 pub free_memory_mb: usize,
41}
42
43// ---------------------------------------------------------------------------
44// NumaTopology
45// ---------------------------------------------------------------------------
46
47/// Snapshot of the machine's NUMA topology.
48#[derive(Debug, Clone)]
49pub struct NumaTopology {
50 /// All NUMA nodes discovered.
51 pub nodes: Vec<NumaNode>,
52 /// Cached count of nodes (== `nodes.len()`).
53 pub num_nodes: usize,
54}
55
56impl NumaTopology {
57 /// Attempt to discover NUMA topology from the operating system.
58 ///
59 /// Discovery order (first success wins):
60 ///
61 /// 1. **sysfs** (Linux only) — parses
62 /// `/sys/devices/system/node/node*/cpulist` and `meminfo`.
63 /// 2. **Single-node fallback** — treats the whole machine as one NUMA node.
64 ///
65 /// NUMA discovery via the `libnuma` C library was removed per the
66 /// COOLJAPAN Pure Rust Policy: sysfs parsing is a complete, dependency-free
67 /// functional replacement for the topology queries libnuma used to provide.
68 pub fn discover() -> Self {
69 #[cfg(target_os = "linux")]
70 {
71 if let Some(topo) = Self::discover_linux() {
72 return topo;
73 }
74 }
75 Self::single_node_fallback()
76 }
77
78 /// Build a single-node topology that covers all logical CPUs.
79 fn single_node_fallback() -> Self {
80 let cpu_count = num_cpus_count();
81 let node = NumaNode {
82 id: 0,
83 cpu_ids: (0..cpu_count).collect(),
84 memory_mb: 0,
85 free_memory_mb: 0,
86 };
87 NumaTopology {
88 num_nodes: 1,
89 nodes: vec![node],
90 }
91 }
92
93 /// Linux-specific topology discovery via sysfs.
94 #[cfg(target_os = "linux")]
95 fn discover_linux() -> Option<Self> {
96 use std::fs;
97 use std::path::Path;
98
99 let node_base = Path::new("/sys/devices/system/node");
100 if !node_base.exists() {
101 return None;
102 }
103
104 let mut nodes: Vec<NumaNode> = Vec::new();
105
106 let mut idx = 0usize;
107 loop {
108 let node_dir = node_base.join(format!("node{idx}"));
109 if !node_dir.exists() {
110 break;
111 }
112
113 // Parse CPU list (e.g. "0-3,8-11").
114 let cpu_ids = fs::read_to_string(node_dir.join("cpulist"))
115 .map(|s| parse_cpu_list(s.trim()))
116 .unwrap_or_default();
117
118 // Parse meminfo for MemTotal and MemFree.
119 let (memory_mb, free_memory_mb) = fs::read_to_string(node_dir.join("meminfo"))
120 .map(|s| parse_meminfo(&s))
121 .unwrap_or((0, 0));
122
123 // Skip memory-only nodes (no CPUs assigned), matching the
124 // behavior of the removed libnuma-backed discovery path.
125 if !cpu_ids.is_empty() {
126 nodes.push(NumaNode {
127 id: idx,
128 cpu_ids,
129 memory_mb,
130 free_memory_mb,
131 });
132 }
133 idx += 1;
134 }
135
136 if nodes.is_empty() {
137 return None;
138 }
139
140 let num_nodes = nodes.len();
141 Some(NumaTopology { nodes, num_nodes })
142 }
143
144 /// Return the NUMA node that owns `cpu_id`, or `None` if not found.
145 pub fn node_for_cpu(&self, cpu_id: usize) -> Option<usize> {
146 self.nodes
147 .iter()
148 .find(|n| n.cpu_ids.contains(&cpu_id))
149 .map(|n| n.id)
150 }
151
152 /// Best-effort NUMA node for the currently running thread.
153 ///
154 /// Uses the CPU affinity mask on Linux; falls back to node 0 elsewhere.
155 pub fn current_node(&self) -> usize {
156 let cpu = current_cpu_id();
157 self.node_for_cpu(cpu).unwrap_or(0)
158 }
159}
160
161// ---------------------------------------------------------------------------
162// Helpers
163// ---------------------------------------------------------------------------
164
165/// Return the number of logical CPUs without pulling in external crates.
166fn num_cpus_count() -> usize {
167 // std::thread::available_parallelism is stable since Rust 1.59.
168 std::thread::available_parallelism()
169 .map(|n| n.get())
170 .unwrap_or(1)
171}
172
173/// Best-effort current CPU id. Returns 0 if unavailable.
174fn current_cpu_id() -> usize {
175 #[cfg(target_os = "linux")]
176 {
177 // sched_getcpu() is a cheap VDSO call on Linux.
178 extern "C" {
179 fn sched_getcpu() -> std::os::raw::c_int;
180 }
181 let cpu = unsafe { sched_getcpu() };
182 if cpu >= 0 {
183 return cpu as usize;
184 }
185 }
186 0
187}
188
189/// Parse a Linux cpulist string like "0-3,8,10-11" into a Vec<usize>.
190#[cfg(target_os = "linux")]
191fn parse_cpu_list(s: &str) -> Vec<usize> {
192 let mut cpus = Vec::new();
193 for part in s.split(',') {
194 let part = part.trim();
195 if part.is_empty() {
196 continue;
197 }
198 if let Some((lo, hi)) = part.split_once('-') {
199 let lo: usize = lo.trim().parse().unwrap_or(0);
200 let hi: usize = hi.trim().parse().unwrap_or(0);
201 for c in lo..=hi {
202 cpus.push(c);
203 }
204 } else if let Ok(c) = part.parse::<usize>() {
205 cpus.push(c);
206 }
207 }
208 cpus
209}
210
211/// Parse Linux node meminfo and return (total_mb, free_mb).
212#[cfg(target_os = "linux")]
213fn parse_meminfo(s: &str) -> (usize, usize) {
214 let mut total = 0usize;
215 let mut free = 0usize;
216 for line in s.lines() {
217 if line.contains("MemTotal") {
218 total = extract_kb(line) / 1024;
219 } else if line.contains("MemFree") {
220 free = extract_kb(line) / 1024;
221 }
222 }
223 (total, free)
224}
225
226#[cfg(target_os = "linux")]
227fn extract_kb(line: &str) -> usize {
228 // Lines look like: "Node 0 MemTotal: 16777216 kB"
229 line.split_whitespace()
230 .find_map(|w| w.parse::<usize>().ok())
231 .unwrap_or(0)
232}
233
234// ---------------------------------------------------------------------------
235// NumaBuffer
236// ---------------------------------------------------------------------------
237
238/// A heap-allocated buffer tagged with the NUMA node it logically belongs to.
239///
240/// Allocation itself is performed by the standard allocator; the `node_id` tag
241/// is advisory and can be used by higher-level algorithms to prefer data
242/// movement within a node.
243pub struct NumaBuffer<T> {
244 data: Vec<T>,
245 node_id: usize,
246}
247
248impl<T: Default + Clone> NumaBuffer<T> {
249 /// Allocate a zero-initialised buffer of `size` elements tagged to `node_id`.
250 pub fn new(size: usize, node_id: usize) -> Self {
251 NumaBuffer {
252 data: vec![T::default(); size],
253 node_id,
254 }
255 }
256
257 /// Shared slice view.
258 pub fn as_slice(&self) -> &[T] {
259 &self.data
260 }
261
262 /// Mutable slice view.
263 pub fn as_mut_slice(&mut self) -> &mut [T] {
264 &mut self.data
265 }
266
267 /// The NUMA node this buffer was tagged with.
268 pub fn node_id(&self) -> usize {
269 self.node_id
270 }
271
272 /// Number of elements.
273 pub fn len(&self) -> usize {
274 self.data.len()
275 }
276
277 /// `true` iff the buffer holds no elements.
278 pub fn is_empty(&self) -> bool {
279 self.data.is_empty()
280 }
281}
282
283// ---------------------------------------------------------------------------
284// NumaAwarePool
285// ---------------------------------------------------------------------------
286
287/// A per-NUMA-node pool of reusable fixed-size blocks.
288///
289/// Blocks are allocated on demand and returned to the per-node cache when
290/// released. This avoids repeated heap allocations in hot loops while keeping
291/// locality information.
292///
293/// # Type parameter
294///
295/// `T` must implement `Default + Clone + Send`. The `Send` bound is required
296/// because blocks can be moved between threads (caller's responsibility to
297/// only hand a block to a thread that matches its node affinity).
298pub struct NumaAwarePool<T: Default + Clone + Send> {
299 /// `per_node_pools[node_id]` is a stack of cached free blocks.
300 per_node_pools: Vec<Vec<Vec<T>>>,
301 block_size: usize,
302 topology: NumaTopology,
303}
304
305impl<T: Default + Clone + Send> NumaAwarePool<T> {
306 /// Create a new pool with the specified `block_size`.
307 ///
308 /// The topology is discovered automatically.
309 pub fn new(block_size: usize) -> Self {
310 let topology = NumaTopology::discover();
311 let num_nodes = topology.num_nodes;
312 NumaAwarePool {
313 per_node_pools: vec![Vec::new(); num_nodes],
314 block_size,
315 topology,
316 }
317 }
318
319 /// Allocate a block.
320 ///
321 /// If `node_id` is `Some(n)` and `n` is valid, a cached block from that
322 /// node is returned (or a fresh one allocated and tagged). `None` selects
323 /// the current thread's preferred node.
324 pub fn allocate(&mut self, node_id: Option<usize>) -> Vec<T> {
325 let node = self.resolve_node(node_id);
326 if let Some(block) = self.per_node_pools[node].pop() {
327 return block;
328 }
329 vec![T::default(); self.block_size]
330 }
331
332 /// Return a block to the pool.
333 ///
334 /// `node_id` semantics are the same as for [`allocate`](Self::allocate).
335 /// If the block's length differs from `block_size` it is discarded.
336 pub fn deallocate(&mut self, block: Vec<T>, node_id: Option<usize>) {
337 if block.len() != self.block_size {
338 return; // Discard non-conforming blocks silently.
339 }
340 let node = self.resolve_node(node_id);
341 self.per_node_pools[node].push(block);
342 }
343
344 /// Return `(node_id, cached_block_count)` for every node.
345 pub fn stats(&self) -> Vec<(usize, usize)> {
346 self.per_node_pools
347 .iter()
348 .enumerate()
349 .map(|(i, pool)| (i, pool.len()))
350 .collect()
351 }
352
353 fn resolve_node(&self, hint: Option<usize>) -> usize {
354 let n = match hint {
355 Some(id) => id,
356 None => self.topology.current_node(),
357 };
358 n.min(self.topology.num_nodes.saturating_sub(1))
359 }
360}
361
362// ---------------------------------------------------------------------------
363// Validate topology helper (public utility)
364// ---------------------------------------------------------------------------
365
366/// Validate that a node_id is within the bounds of the topology.
367pub fn validate_node_id(topology: &NumaTopology, node_id: usize) -> CoreResult<()> {
368 if node_id < topology.num_nodes {
369 Ok(())
370 } else {
371 Err(CoreError::InvalidArgument(ErrorContext::new(format!(
372 "NUMA node_id {node_id} is out of range (topology has {} nodes)",
373 topology.num_nodes
374 ))))
375 }
376}
377
378// ---------------------------------------------------------------------------
379// Tests
380// ---------------------------------------------------------------------------
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385
386 #[test]
387 fn test_topology_discover_returns_at_least_one_node() {
388 let topo = NumaTopology::discover();
389 assert!(topo.num_nodes >= 1);
390 assert_eq!(topo.nodes.len(), topo.num_nodes);
391 for node in &topo.nodes {
392 assert!(!node.cpu_ids.is_empty());
393 }
394 }
395
396 #[test]
397 fn test_current_node_within_bounds() {
398 let topo = NumaTopology::discover();
399 let cur = topo.current_node();
400 assert!(cur < topo.num_nodes);
401 }
402
403 #[test]
404 fn test_numa_buffer_basic() {
405 let mut buf: NumaBuffer<f64> = NumaBuffer::new(1024, 0);
406 assert_eq!(buf.len(), 1024);
407 assert!(!buf.is_empty());
408 assert_eq!(buf.node_id(), 0);
409
410 // All elements default-initialised to 0.0.
411 assert!(buf.as_slice().iter().all(|&v| v == 0.0));
412
413 // Mutate via slice.
414 buf.as_mut_slice()[0] = 3.15;
415 assert_eq!(buf.as_slice()[0], 3.15);
416 }
417
418 #[test]
419 fn test_numa_buffer_empty() {
420 let buf: NumaBuffer<u8> = NumaBuffer::new(0, 0);
421 assert!(buf.is_empty());
422 assert_eq!(buf.len(), 0);
423 }
424
425 #[test]
426 fn test_pool_allocate_deallocate() {
427 let mut pool: NumaAwarePool<u64> = NumaAwarePool::new(64);
428
429 // Allocate from node 0.
430 let block = pool.allocate(Some(0));
431 assert_eq!(block.len(), 64);
432 assert!(block.iter().all(|&v| v == 0));
433
434 // Return to pool.
435 pool.deallocate(block, Some(0));
436
437 let stats = pool.stats();
438 assert_eq!(stats[0].1, 1); // One cached block at node 0.
439
440 // Re-allocate reuses the cached block.
441 let _block2 = pool.allocate(Some(0));
442 let stats2 = pool.stats();
443 assert_eq!(stats2[0].1, 0); // Cache should be empty now.
444 }
445
446 #[test]
447 fn test_pool_wrong_size_discarded() {
448 let mut pool: NumaAwarePool<u32> = NumaAwarePool::new(32);
449 // Deallocate a block with the wrong size; it should be discarded.
450 pool.deallocate(vec![0u32; 16], Some(0));
451 let stats = pool.stats();
452 assert_eq!(stats[0].1, 0);
453 }
454
455 #[test]
456 fn test_pool_current_node_allocation() {
457 let mut pool: NumaAwarePool<i32> = NumaAwarePool::new(8);
458 // Allocate without specifying node; should use current node.
459 let block = pool.allocate(None);
460 assert_eq!(block.len(), 8);
461 pool.deallocate(block, None);
462 }
463
464 #[test]
465 fn test_validate_node_id() {
466 let topo = NumaTopology::discover();
467 assert!(validate_node_id(&topo, 0).is_ok());
468 assert!(validate_node_id(&topo, topo.num_nodes).is_err());
469 }
470
471 #[test]
472 fn test_node_for_cpu() {
473 let topo = NumaTopology::discover();
474 // CPU 0 must belong to some node.
475 let node = topo.node_for_cpu(0);
476 assert!(node.is_some());
477 // A very large CPU id should not be found.
478 assert!(topo.node_for_cpu(usize::MAX).is_none());
479 }
480}