Skip to main content

scirs2_core/memory/
numa_bandwidth.rs

1//! Cross-NUMA bandwidth measurement and routing.
2//!
3//! Measures effective memory bandwidth between NUMA nodes and uses the
4//! resulting matrix to make optimal data-placement decisions.
5//!
6//! On systems without `libnuma` (or without multiple NUMA nodes), the module
7//! transparently returns a single-node matrix so code using it is portable.
8//!
9//! # Design
10//!
11//! ```text
12//!   probe_bandwidth_matrix()
13//!         │
14//!         ├── measure_copy_bandwidth(size)   (warm-up + 3 timed copies)
15//!         └── NumaBandwidthMatrix::uniform(1, bw, lat)  (fallback)
16//! ```
17//!
18//! For systems with runtime NUMA topology detection (future: via `libnuma`
19//! feature), the matrix would be filled with per-pair measurements.  The
20//! present implementation probes the local node and builds a uniform matrix;
21//! the API is designed to accommodate full multi-node measurement without
22//! breaking changes.
23//!
24//! # Example
25//!
26//! ```rust
27//! use scirs2_core::memory::numa_bandwidth::{probe_bandwidth_matrix, optimal_placement_node};
28//!
29//! let matrix = probe_bandwidth_matrix();
30//! let target = optimal_placement_node(&matrix, 0, 4 * 1024 * 1024);
31//! assert!(target < matrix.n_nodes);
32//! ```
33
34use std::time::Instant;
35
36// ---------------------------------------------------------------------------
37// BandwidthMeasurement
38// ---------------------------------------------------------------------------
39
40/// Bandwidth measurement between two NUMA nodes (or within a single node).
41#[derive(Debug, Clone)]
42pub struct BandwidthMeasurement {
43    /// Source NUMA node index.
44    pub from_node: usize,
45    /// Destination NUMA node index.
46    pub to_node: usize,
47    /// Measured bandwidth in GB/s.
48    pub bandwidth_gb_s: f64,
49    /// Average per-transfer latency in nanoseconds.
50    pub latency_ns: f64,
51    /// Transfer size used for the measurement.
52    pub transfer_size_bytes: usize,
53}
54
55// ---------------------------------------------------------------------------
56// NumaBandwidthMatrix
57// ---------------------------------------------------------------------------
58
59/// NUMA bandwidth matrix: `bandwidth[from][to]` in GB/s, `latency[from][to]` in ns.
60#[derive(Debug, Clone)]
61pub struct NumaBandwidthMatrix {
62    /// Number of NUMA nodes.
63    pub n_nodes: usize,
64    /// Bandwidth from node `i` to node `j` in GB/s.  Row-major: `[from][to]`.
65    pub bandwidth: Vec<Vec<f64>>,
66    /// Latency from node `i` to node `j` in nanoseconds.  Row-major: `[from][to]`.
67    pub latency: Vec<Vec<f64>>,
68}
69
70impl NumaBandwidthMatrix {
71    /// Create a uniform matrix where every (i, j) pair has the same bandwidth
72    /// and latency.  Useful as a single-node fallback.
73    pub fn uniform(n_nodes: usize, bandwidth_gb_s: f64, latency_ns: f64) -> Self {
74        let row = vec![bandwidth_gb_s; n_nodes.max(1)];
75        let lat_row = vec![latency_ns; n_nodes.max(1)];
76        let n = n_nodes.max(1);
77        Self {
78            n_nodes: n,
79            bandwidth: vec![row; n],
80            latency: vec![lat_row; n],
81        }
82    }
83
84    /// Get bandwidth from node `from` to node `to` in GB/s.
85    ///
86    /// Returns `0.0` if either index is out of range.
87    pub fn get_bandwidth(&self, from: usize, to: usize) -> f64 {
88        self.bandwidth
89            .get(from)
90            .and_then(|row| row.get(to))
91            .copied()
92            .unwrap_or(0.0)
93    }
94
95    /// Get latency from node `from` to node `to` in nanoseconds.
96    ///
97    /// Returns `f64::MAX` if either index is out of range.
98    pub fn get_latency(&self, from: usize, to: usize) -> f64 {
99        self.latency
100            .get(from)
101            .and_then(|row| row.get(to))
102            .copied()
103            .unwrap_or(f64::MAX)
104    }
105
106    /// Find the highest-bandwidth route from `src` to `dst`.
107    ///
108    /// For a 1-node matrix this is always the diagonal.
109    /// For multi-node matrices, returns the direct bandwidth (no intermediate
110    /// hop logic needed for cache-coherent NUMA).
111    ///
112    /// Returns the bandwidth value in GB/s.
113    pub fn best_route(&self, src: usize, dst: usize) -> f64 {
114        self.get_bandwidth(src, dst)
115    }
116
117    /// Find the node with the highest average outgoing bandwidth.
118    ///
119    /// Ties are broken by choosing the lower node index.
120    pub fn highest_bandwidth_node(&self) -> usize {
121        let mut best_node = 0usize;
122        let mut best_avg = f64::NEG_INFINITY;
123
124        for from in 0..self.n_nodes {
125            let row = &self.bandwidth[from];
126            let avg = if row.is_empty() {
127                0.0
128            } else {
129                row.iter().sum::<f64>() / row.len() as f64
130            };
131            if avg > best_avg {
132                best_avg = avg;
133                best_node = from;
134            }
135        }
136        best_node
137    }
138
139    /// Format the bandwidth matrix as a human-readable table.
140    pub fn display(&self) -> String {
141        let mut out = String::from("NUMA Bandwidth Matrix (GB/s):\n");
142        out.push_str("     ");
143        for j in 0..self.n_nodes {
144            out.push_str(&format!("  {:>6}", format!("N{j}")));
145        }
146        out.push('\n');
147
148        for (i, row) in self.bandwidth.iter().enumerate() {
149            out.push_str(&format!("N{i:<4}"));
150            for &bw in row {
151                out.push_str(&format!("  {:>6.2}", bw));
152            }
153            out.push('\n');
154        }
155        out
156    }
157
158    /// Number of NUMA nodes in this matrix.
159    pub fn node_count(&self) -> usize {
160        self.n_nodes
161    }
162}
163
164// ---------------------------------------------------------------------------
165// measure_copy_bandwidth
166// ---------------------------------------------------------------------------
167
168/// Measure actual memory copy bandwidth using a warm-up pass followed by
169/// three timed copies of `transfer_size_bytes` bytes.
170///
171/// The result represents intra-node (local) bandwidth; use multiple calls to
172/// measure cross-node bandwidth once libnuma pinning is available.
173pub fn measure_copy_bandwidth(transfer_size_bytes: usize) -> BandwidthMeasurement {
174    // Allocate source and destination buffers.
175    let src: Vec<u8> = vec![0xABu8; transfer_size_bytes];
176    let mut dst = vec![0u8; transfer_size_bytes];
177
178    // Warm-up pass (pulls buffers into cache / TLB).
179    dst.copy_from_slice(&src);
180
181    // Prevent the compiler from eliding the copies.
182    let _ = dst[transfer_size_bytes / 2];
183
184    // Timed measurement: 3 copies.
185    let repetitions: u64 = 3;
186    let start = Instant::now();
187    for _ in 0..repetitions {
188        dst.copy_from_slice(&src);
189    }
190    let elapsed = start.elapsed();
191
192    // Prevent elision of the last copy.
193    let _ = dst[0];
194
195    let bytes_transferred = transfer_size_bytes as u64 * repetitions;
196    let elapsed_secs = elapsed.as_secs_f64();
197
198    // Guard against zero elapsed time (possible on very fast CPUs).
199    let bandwidth_gb_s = if elapsed_secs > 0.0 {
200        bytes_transferred as f64 / elapsed_secs / 1e9
201    } else {
202        f64::MAX
203    };
204
205    let latency_ns = if repetitions > 0 {
206        elapsed.as_nanos() as f64 / repetitions as f64
207    } else {
208        0.0
209    };
210
211    BandwidthMeasurement {
212        from_node: 0,
213        to_node: 0,
214        bandwidth_gb_s,
215        latency_ns,
216        transfer_size_bytes,
217    }
218}
219
220// ---------------------------------------------------------------------------
221// probe_bandwidth_matrix
222// ---------------------------------------------------------------------------
223
224/// Build a bandwidth matrix by probing memory copy throughput.
225///
226/// On systems without `libnuma` or without multiple NUMA nodes, returns a
227/// 1-node uniform matrix populated with the measured copy bandwidth.
228///
229/// This function performs real memory copies (4 MiB probe by default) so
230/// it should be called once at startup and the result cached.
231pub fn probe_bandwidth_matrix() -> NumaBandwidthMatrix {
232    // Future: detect n_nodes from libnuma or sysfs when available.
233    // Current: single-node fallback.
234    let n_nodes = detect_numa_node_count();
235    let probe_size = 4 * 1024 * 1024; // 4 MiB
236    let measurement = measure_copy_bandwidth(probe_size);
237
238    NumaBandwidthMatrix::uniform(n_nodes, measurement.bandwidth_gb_s, measurement.latency_ns)
239}
240
241/// Detect the number of NUMA nodes available on this system.
242///
243/// Falls back to 1 when NUMA topology cannot be determined without `libnuma`.
244fn detect_numa_node_count() -> usize {
245    // On Linux we can read /sys/devices/system/node/online to get the count.
246    #[cfg(target_os = "linux")]
247    {
248        if let Some(count) = try_read_linux_numa_count() {
249            return count;
250        }
251    }
252    1
253}
254
255#[cfg(target_os = "linux")]
256fn try_read_linux_numa_count() -> Option<usize> {
257    use std::fs;
258    let contents = fs::read_to_string("/sys/devices/system/node/online").ok()?;
259    // Format is e.g. "0-3" or "0" or "0,2-4".
260    // Count the nodes by parsing the range/list.
261    parse_node_count_from_range(contents.trim())
262}
263
264/// Parse a node count from a Linux cpumask/nodelist string like "0-3" or "0,2,4-6".
265fn parse_node_count_from_range(s: &str) -> Option<usize> {
266    let mut count = 0usize;
267    for part in s.split(',') {
268        let part = part.trim();
269        if part.is_empty() {
270            continue;
271        }
272        if let Some((lo_str, hi_str)) = part.split_once('-') {
273            let lo: usize = lo_str.trim().parse().ok()?;
274            let hi: usize = hi_str.trim().parse().ok()?;
275            if hi >= lo {
276                count += hi - lo + 1;
277            }
278        } else {
279            // Single node id.
280            let _id: usize = part.parse().ok()?;
281            count += 1;
282        }
283    }
284    if count > 0 {
285        Some(count)
286    } else {
287        None
288    }
289}
290
291// ---------------------------------------------------------------------------
292// optimal_placement_node
293// ---------------------------------------------------------------------------
294
295/// Route a data transfer to maximise bandwidth.
296///
297/// Returns the optimal target NUMA node for placement of `data_size` bytes
298/// that will be accessed from `src_node`.
299///
300/// For same-node access this returns `src_node`; for cross-node access it
301/// returns the node with the highest bandwidth from `src_node`.
302pub fn optimal_placement_node(
303    matrix: &NumaBandwidthMatrix,
304    src_node: usize,
305    data_size: usize,
306) -> usize {
307    let _ = data_size; // Size could influence threshold decisions in future.
308
309    if src_node >= matrix.n_nodes {
310        return 0;
311    }
312
313    // Find the destination node with the highest bandwidth from src_node.
314    let bandwidth_row = &matrix.bandwidth[src_node];
315    let mut best_node = src_node;
316    let mut best_bw = bandwidth_row.get(src_node).copied().unwrap_or(0.0);
317
318    for (to, &bw) in bandwidth_row.iter().enumerate() {
319        if bw > best_bw {
320            best_bw = bw;
321            best_node = to;
322        }
323    }
324    best_node
325}
326
327// ---------------------------------------------------------------------------
328// Tests
329// ---------------------------------------------------------------------------
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn test_bandwidth_matrix_uniform() {
337        let matrix = NumaBandwidthMatrix::uniform(2, 50.0, 100.0);
338        assert_eq!(matrix.n_nodes, 2);
339        assert_eq!(matrix.get_bandwidth(0, 0), 50.0);
340        assert_eq!(matrix.get_bandwidth(0, 1), 50.0);
341        assert_eq!(matrix.get_bandwidth(1, 0), 50.0);
342        assert_eq!(matrix.get_bandwidth(1, 1), 50.0);
343        assert_eq!(matrix.get_latency(0, 1), 100.0);
344        // Out of range returns sentinel.
345        assert_eq!(matrix.get_bandwidth(5, 0), 0.0);
346        assert_eq!(matrix.get_latency(5, 0), f64::MAX);
347    }
348
349    #[test]
350    fn test_bandwidth_matrix_single_node_fallback() {
351        // uniform with 0 nodes should produce a 1-node matrix.
352        let matrix = NumaBandwidthMatrix::uniform(0, 10.0, 200.0);
353        assert_eq!(matrix.n_nodes, 1);
354        assert_eq!(matrix.get_bandwidth(0, 0), 10.0);
355    }
356
357    #[test]
358    fn test_bandwidth_measure() {
359        // 1 MB probe — just verify we get a positive bandwidth.
360        let m = measure_copy_bandwidth(1024 * 1024);
361        assert!(m.bandwidth_gb_s > 0.0, "bandwidth must be positive");
362        assert!(m.latency_ns > 0.0, "latency must be positive");
363        assert_eq!(m.transfer_size_bytes, 1024 * 1024);
364        assert_eq!(m.from_node, 0);
365        assert_eq!(m.to_node, 0);
366    }
367
368    #[test]
369    fn test_bandwidth_matrix_display() {
370        let matrix = NumaBandwidthMatrix::uniform(2, 42.0, 80.0);
371        let s = matrix.display();
372        assert!(!s.is_empty(), "display string should not be empty");
373        assert!(s.contains("42.00"), "should contain bandwidth value");
374    }
375
376    #[test]
377    fn test_optimal_placement_single_node() {
378        let matrix = NumaBandwidthMatrix::uniform(1, 50.0, 100.0);
379        let node = optimal_placement_node(&matrix, 0, 4 * 1024 * 1024);
380        assert_eq!(node, 0, "single-node system => always node 0");
381    }
382
383    #[test]
384    fn test_optimal_placement_out_of_range() {
385        let matrix = NumaBandwidthMatrix::uniform(2, 50.0, 100.0);
386        // src_node >= n_nodes should return 0 safely.
387        let node = optimal_placement_node(&matrix, 99, 1024);
388        assert_eq!(node, 0, "out-of-range src should return 0");
389    }
390
391    #[test]
392    fn test_optimal_placement_multi_node_prefers_high_bw() {
393        let n = 3;
394        let mut matrix = NumaBandwidthMatrix::uniform(n, 10.0, 100.0);
395        // Make node 0 -> node 2 the highest bandwidth link.
396        matrix.bandwidth[0][2] = 100.0;
397        let node = optimal_placement_node(&matrix, 0, 1024);
398        assert_eq!(node, 2, "should prefer node 2 with highest bandwidth");
399    }
400
401    #[test]
402    fn test_highest_bandwidth_node() {
403        let n = 3;
404        let mut matrix = NumaBandwidthMatrix::uniform(n, 10.0, 100.0);
405        // Make node 1 have the highest outgoing bandwidth overall.
406        for j in 0..n {
407            matrix.bandwidth[1][j] = 50.0;
408        }
409        assert_eq!(
410            matrix.highest_bandwidth_node(),
411            1,
412            "node 1 should have the highest average outgoing BW"
413        );
414    }
415
416    #[test]
417    fn test_best_route() {
418        let matrix = NumaBandwidthMatrix::uniform(2, 30.0, 50.0);
419        assert_eq!(matrix.best_route(0, 1), 30.0);
420        assert_eq!(matrix.best_route(1, 0), 30.0);
421    }
422
423    #[test]
424    fn test_probe_bandwidth_matrix_returns_valid() {
425        let matrix = probe_bandwidth_matrix();
426        assert!(matrix.n_nodes >= 1, "must have at least one node");
427        assert!(
428            matrix.get_bandwidth(0, 0) > 0.0,
429            "local bandwidth must be positive"
430        );
431    }
432
433    #[test]
434    fn test_parse_node_count_from_range() {
435        assert_eq!(parse_node_count_from_range("0"), Some(1));
436        assert_eq!(parse_node_count_from_range("0-3"), Some(4));
437        assert_eq!(parse_node_count_from_range("0,2"), Some(2));
438        assert_eq!(parse_node_count_from_range("0-1,4-7"), Some(6));
439        assert_eq!(parse_node_count_from_range(""), None);
440    }
441}