Skip to main content

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}