Skip to main content

oxicuda_memory/numa/
numa_buffer.rs

1//! NUMA-aware host allocation bookkeeping.
2//!
3//! On multi-socket hosts, page-locked memory used for host↔device DMA performs
4//! best when it is physically resident on the NUMA node electrically closest to
5//! the target GPU (i.e. the node whose PCIe root complex hosts the device).
6//! `libnuma`'s `numa_alloc_onnode` lets an application bind an allocation to a
7//! specific node; the matching CUDA pattern is to pin that allocation with
8//! `cuMemHostRegister`.
9//!
10//! This module models the *host-side topology and policy* required to make that
11//! decision without a GPU present: a [`NumaTopology`] describing nodes and the
12//! NUMA distance matrix, a [`closest_node_to_gpu`] selector, and a
13//! [`NumaBuffer`] descriptor that records which node an allocation is bound to
14//! together with its byte footprint.  The physical `numa_alloc_onnode` /
15//! `cuMemHostRegister` calls require the real platform and live behind the
16//! device-gated path; everything here is deterministic and unit-testable.
17//!
18//! NUMA distances follow the ACPI SLIT convention: the distance from a node to
19//! itself is `10`, and remote distances are larger multiples (a one-hop remote
20//! node is typically `20`, i.e. ~2× local latency).
21//!
22//! # Example
23//!
24//! ```rust
25//! # use oxicuda_memory::numa::numa_buffer::*;
26//! // Two-socket box: node 0 and node 1, remote distance 20.
27//! let topo = NumaTopology::two_node(20);
28//! // A GPU attached to node 1's root complex should bind host memory to node 1.
29//! let node = closest_node_to_gpu(&topo, 1).expect("node");
30//! assert_eq!(node, 1);
31//!
32//! let buf = NumaBuffer::on_node(&topo, node, 4096).expect("alloc");
33//! assert_eq!(buf.node(), 1);
34//! assert_eq!(buf.byte_size(), 4096);
35//! # Ok::<(), oxicuda_driver::error::CudaError>(())
36//! ```
37
38use oxicuda_driver::error::{CudaError, CudaResult};
39
40/// The ACPI SLIT local-node distance (node to itself).
41pub const LOCAL_NUMA_DISTANCE: u8 = 10;
42
43// ---------------------------------------------------------------------------
44// NumaTopology
45// ---------------------------------------------------------------------------
46
47/// A model of the host NUMA topology.
48///
49/// Stores the number of nodes and a flattened, row-major distance matrix in
50/// ACPI SLIT units.  The matrix is symmetric for physically realistic
51/// topologies but this type does not enforce symmetry — it only enforces that
52/// the diagonal is the local distance and that the matrix is square.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct NumaTopology {
55    node_count: usize,
56    /// Row-major `node_count × node_count` distance matrix.
57    distances: Vec<u8>,
58}
59
60impl NumaTopology {
61    /// Builds a topology from an explicit row-major distance matrix.
62    ///
63    /// # Errors
64    ///
65    /// * [`CudaError::InvalidValue`] if `node_count` is zero, if `distances`
66    ///   does not have exactly `node_count * node_count` entries, or if any
67    ///   diagonal entry is not [`LOCAL_NUMA_DISTANCE`].
68    pub fn new(node_count: usize, distances: Vec<u8>) -> CudaResult<Self> {
69        if node_count == 0 {
70            return Err(CudaError::InvalidValue);
71        }
72        let expected = node_count
73            .checked_mul(node_count)
74            .ok_or(CudaError::InvalidValue)?;
75        if distances.len() != expected {
76            return Err(CudaError::InvalidValue);
77        }
78        for node in 0..node_count {
79            if distances[node * node_count + node] != LOCAL_NUMA_DISTANCE {
80                return Err(CudaError::InvalidValue);
81            }
82        }
83        Ok(Self {
84            node_count,
85            distances,
86        })
87    }
88
89    /// Builds a uniform topology of `node_count` nodes where every distinct
90    /// pair has distance `remote`.
91    ///
92    /// `remote` is clamped to be at least `LOCAL_NUMA_DISTANCE + 1` so remote
93    /// access is always modelled as strictly slower than local.
94    ///
95    /// # Errors
96    ///
97    /// * [`CudaError::InvalidValue`] if `node_count` is zero.
98    pub fn uniform(node_count: usize, remote: u8) -> CudaResult<Self> {
99        if node_count == 0 {
100            return Err(CudaError::InvalidValue);
101        }
102        let remote = remote.max(LOCAL_NUMA_DISTANCE.saturating_add(1));
103        let mut distances = vec![remote; node_count * node_count];
104        for node in 0..node_count {
105            distances[node * node_count + node] = LOCAL_NUMA_DISTANCE;
106        }
107        Ok(Self {
108            node_count,
109            distances,
110        })
111    }
112
113    /// Convenience constructor for the common two-socket layout.
114    ///
115    /// `remote` is the cross-socket distance (typically `20`).
116    #[must_use]
117    pub fn two_node(remote: u8) -> Self {
118        // node_count == 2 is non-zero, so `uniform` cannot fail.
119        Self::uniform(2, remote).unwrap_or(Self {
120            node_count: 2,
121            distances: vec![LOCAL_NUMA_DISTANCE, 20, 20, LOCAL_NUMA_DISTANCE],
122        })
123    }
124
125    /// A single-node topology (UMA / non-NUMA host).
126    #[must_use]
127    pub fn single_node() -> Self {
128        Self {
129            node_count: 1,
130            distances: vec![LOCAL_NUMA_DISTANCE],
131        }
132    }
133
134    /// The number of NUMA nodes.
135    #[inline]
136    #[must_use]
137    pub fn node_count(&self) -> usize {
138        self.node_count
139    }
140
141    /// Returns the distance between `from` and `to`, or `None` if either index
142    /// is out of range.
143    #[must_use]
144    pub fn distance(&self, from: usize, to: usize) -> Option<u8> {
145        if from >= self.node_count || to >= self.node_count {
146            return None;
147        }
148        Some(self.distances[from * self.node_count + to])
149    }
150
151    /// Returns the node with the smallest distance from `from`.
152    ///
153    /// Ties are broken by the lowest node index.  Returns `None` if `from` is
154    /// out of range.
155    #[must_use]
156    pub fn nearest_node(&self, from: usize) -> Option<usize> {
157        if from >= self.node_count {
158            return None;
159        }
160        let mut best = from;
161        let mut best_dist = self.distances[from * self.node_count + from];
162        for to in 0..self.node_count {
163            let d = self.distances[from * self.node_count + to];
164            if d < best_dist {
165                best_dist = d;
166                best = to;
167            }
168        }
169        Some(best)
170    }
171}
172
173// ---------------------------------------------------------------------------
174// closest_node_to_gpu
175// ---------------------------------------------------------------------------
176
177/// Returns the NUMA node closest to a GPU whose PCIe root complex is attached
178/// to `gpu_home_node`.
179///
180/// In a real deployment `gpu_home_node` would come from
181/// `cudaDeviceGetPCIBusId` + a sysfs lookup of the device's
182/// `numa_node` attribute.  The closest *host* node for binding pinned memory is
183/// then the GPU's home node itself (its memory is electrically local to that
184/// socket), which this function validates against the topology.
185///
186/// # Errors
187///
188/// * [`CudaError::InvalidValue`] if `gpu_home_node` is not a valid node index
189///   in `topology`.
190pub fn closest_node_to_gpu(topology: &NumaTopology, gpu_home_node: usize) -> CudaResult<usize> {
191    if gpu_home_node >= topology.node_count() {
192        return Err(CudaError::InvalidValue);
193    }
194    // The home node is by construction the nearest (distance == local).
195    topology
196        .nearest_node(gpu_home_node)
197        .ok_or(CudaError::InvalidValue)
198}
199
200// ---------------------------------------------------------------------------
201// NumaBuffer
202// ---------------------------------------------------------------------------
203
204/// Host-side descriptor for a host allocation bound to a NUMA node.
205///
206/// Records the bound node and the byte footprint.  This models the result of a
207/// `numa_alloc_onnode(byte_size, node)` followed by `cuMemHostRegister`; the
208/// descriptor carries the accounting that callers use to verify locality
209/// without owning a real pointer here.
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub struct NumaBuffer {
212    node: usize,
213    byte_size: usize,
214}
215
216impl NumaBuffer {
217    /// Plans a NUMA-bound host allocation of `byte_size` bytes on `node`.
218    ///
219    /// # Errors
220    ///
221    /// * [`CudaError::InvalidValue`] if `byte_size` is zero or `node` is not a
222    ///   valid index in `topology`.
223    pub fn on_node(topology: &NumaTopology, node: usize, byte_size: usize) -> CudaResult<Self> {
224        if byte_size == 0 {
225            return Err(CudaError::InvalidValue);
226        }
227        if node >= topology.node_count() {
228            return Err(CudaError::InvalidValue);
229        }
230        Ok(Self { node, byte_size })
231    }
232
233    /// Plans a host allocation bound to the node closest to a GPU whose home
234    /// node is `gpu_home_node`.
235    ///
236    /// # Errors
237    ///
238    /// Forwards errors from [`closest_node_to_gpu`] and the byte-size /
239    /// node-range checks.
240    pub fn closest_to_gpu(
241        topology: &NumaTopology,
242        gpu_home_node: usize,
243        byte_size: usize,
244    ) -> CudaResult<Self> {
245        let node = closest_node_to_gpu(topology, gpu_home_node)?;
246        Self::on_node(topology, node, byte_size)
247    }
248
249    /// The NUMA node this allocation is bound to.
250    #[inline]
251    #[must_use]
252    pub fn node(&self) -> usize {
253        self.node
254    }
255
256    /// The byte footprint of the allocation.
257    #[inline]
258    #[must_use]
259    pub fn byte_size(&self) -> usize {
260        self.byte_size
261    }
262
263    /// Returns `true` if this allocation is local to `access_node` (i.e. the
264    /// accessing node *is* the bound node).
265    #[inline]
266    #[must_use]
267    pub fn is_local_to(&self, access_node: usize) -> bool {
268        self.node == access_node
269    }
270
271    /// Returns the NUMA distance an access from `access_node` would incur,
272    /// according to `topology`.
273    ///
274    /// Returns `None` if `access_node` is out of range for `topology`.
275    #[must_use]
276    pub fn access_distance(&self, topology: &NumaTopology, access_node: usize) -> Option<u8> {
277        topology.distance(access_node, self.node)
278    }
279}
280
281impl std::fmt::Display for NumaBuffer {
282    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283        write!(
284            f,
285            "NumaBuffer(node={}, {} bytes)",
286            self.node, self.byte_size
287        )
288    }
289}
290
291// ---------------------------------------------------------------------------
292// NumaAllocTracker
293// ---------------------------------------------------------------------------
294
295/// Per-node accounting of NUMA-bound host allocations.
296///
297/// Tracks how many bytes are currently outstanding on each node so that a
298/// scheduler can balance pinned-memory pressure across sockets.
299#[derive(Debug, Clone, PartialEq, Eq)]
300pub struct NumaAllocTracker {
301    per_node_bytes: Vec<usize>,
302}
303
304impl NumaAllocTracker {
305    /// Creates a tracker sized for `topology`'s node count.
306    #[must_use]
307    pub fn new(topology: &NumaTopology) -> Self {
308        Self {
309            per_node_bytes: vec![0; topology.node_count()],
310        }
311    }
312
313    /// Records that `buf` was allocated, adding its bytes to its node's total.
314    ///
315    /// # Errors
316    ///
317    /// * [`CudaError::InvalidValue`] if the buffer's node is out of range for
318    ///   this tracker.
319    pub fn record(&mut self, buf: &NumaBuffer) -> CudaResult<()> {
320        let slot = self
321            .per_node_bytes
322            .get_mut(buf.node())
323            .ok_or(CudaError::InvalidValue)?;
324        *slot = slot.saturating_add(buf.byte_size());
325        Ok(())
326    }
327
328    /// Records that `buf` was freed, subtracting its bytes from its node total.
329    ///
330    /// # Errors
331    ///
332    /// * [`CudaError::InvalidValue`] if the buffer's node is out of range.
333    pub fn release(&mut self, buf: &NumaBuffer) -> CudaResult<()> {
334        let slot = self
335            .per_node_bytes
336            .get_mut(buf.node())
337            .ok_or(CudaError::InvalidValue)?;
338        *slot = slot.saturating_sub(buf.byte_size());
339        Ok(())
340    }
341
342    /// Returns the bytes currently outstanding on `node`, or `None` if out of
343    /// range.
344    #[must_use]
345    pub fn bytes_on_node(&self, node: usize) -> Option<usize> {
346        self.per_node_bytes.get(node).copied()
347    }
348
349    /// Returns the total bytes outstanding across all nodes.
350    #[must_use]
351    pub fn total_bytes(&self) -> usize {
352        self.per_node_bytes
353            .iter()
354            .copied()
355            .fold(0usize, |acc, b| acc.saturating_add(b))
356    }
357
358    /// Returns the index of the least-loaded node (fewest outstanding bytes).
359    ///
360    /// Ties are broken by the lowest index.  Returns `None` only if the
361    /// tracker has zero nodes (which cannot happen via [`new`](Self::new)).
362    #[must_use]
363    pub fn least_loaded_node(&self) -> Option<usize> {
364        self.per_node_bytes
365            .iter()
366            .enumerate()
367            .min_by_key(|&(_, &bytes)| bytes)
368            .map(|(idx, _)| idx)
369    }
370}
371
372// ---------------------------------------------------------------------------
373// Tests
374// ---------------------------------------------------------------------------
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn topology_uniform_diagonal_is_local() {
382        let topo = NumaTopology::uniform(4, 20).expect("topo");
383        assert_eq!(topo.node_count(), 4);
384        for node in 0..4 {
385            assert_eq!(topo.distance(node, node), Some(LOCAL_NUMA_DISTANCE));
386        }
387    }
388
389    #[test]
390    fn topology_uniform_remote_distance() {
391        let topo = NumaTopology::uniform(3, 32).expect("topo");
392        assert_eq!(topo.distance(0, 1), Some(32));
393        assert_eq!(topo.distance(2, 0), Some(32));
394    }
395
396    #[test]
397    fn topology_uniform_clamps_remote_below_local() {
398        // remote smaller than local should be clamped to local+1.
399        let topo = NumaTopology::uniform(2, 5).expect("topo");
400        assert_eq!(topo.distance(0, 1), Some(LOCAL_NUMA_DISTANCE + 1));
401    }
402
403    #[test]
404    fn topology_two_node() {
405        let topo = NumaTopology::two_node(20);
406        assert_eq!(topo.node_count(), 2);
407        assert_eq!(topo.distance(0, 0), Some(10));
408        assert_eq!(topo.distance(0, 1), Some(20));
409        assert_eq!(topo.distance(1, 0), Some(20));
410    }
411
412    #[test]
413    fn topology_single_node() {
414        let topo = NumaTopology::single_node();
415        assert_eq!(topo.node_count(), 1);
416        assert_eq!(topo.distance(0, 0), Some(10));
417        assert_eq!(topo.distance(0, 1), None);
418    }
419
420    #[test]
421    fn topology_new_rejects_zero_nodes() {
422        assert_eq!(NumaTopology::new(0, vec![]), Err(CudaError::InvalidValue));
423    }
424
425    #[test]
426    fn topology_new_rejects_wrong_matrix_size() {
427        // 2 nodes need 4 entries; give 3.
428        assert_eq!(
429            NumaTopology::new(2, vec![10, 20, 20]),
430            Err(CudaError::InvalidValue)
431        );
432    }
433
434    #[test]
435    fn topology_new_rejects_bad_diagonal() {
436        // Diagonal entry (1,1) is 11, not 10.
437        assert_eq!(
438            NumaTopology::new(2, vec![10, 20, 20, 11]),
439            Err(CudaError::InvalidValue)
440        );
441    }
442
443    #[test]
444    fn topology_new_accepts_valid_matrix() {
445        let topo = NumaTopology::new(2, vec![10, 21, 21, 10]).expect("topo");
446        assert_eq!(topo.distance(0, 1), Some(21));
447    }
448
449    #[test]
450    fn topology_distance_out_of_range_is_none() {
451        let topo = NumaTopology::two_node(20);
452        assert_eq!(topo.distance(0, 5), None);
453        assert_eq!(topo.distance(5, 0), None);
454    }
455
456    #[test]
457    fn nearest_node_is_self_for_local() {
458        let topo = NumaTopology::uniform(4, 20).expect("topo");
459        for node in 0..4 {
460            assert_eq!(topo.nearest_node(node), Some(node));
461        }
462    }
463
464    #[test]
465    fn nearest_node_picks_closer_remote() {
466        // 3 nodes; from node 0, node 2 is closer (15) than node 1 (40).
467        // diag = 10. Row 0: [10, 40, 15].
468        let distances = vec![
469            10, 40, 15, // from 0
470            40, 10, 25, // from 1
471            15, 25, 10, // from 2
472        ];
473        let topo = NumaTopology::new(3, distances).expect("topo");
474        // Local (10) is still the minimum, so nearest is self.
475        assert_eq!(topo.nearest_node(0), Some(0));
476    }
477
478    #[test]
479    fn nearest_node_out_of_range_is_none() {
480        let topo = NumaTopology::two_node(20);
481        assert_eq!(topo.nearest_node(2), None);
482    }
483
484    #[test]
485    fn closest_node_to_gpu_returns_home_node() {
486        let topo = NumaTopology::two_node(20);
487        assert_eq!(closest_node_to_gpu(&topo, 0), Ok(0));
488        assert_eq!(closest_node_to_gpu(&topo, 1), Ok(1));
489    }
490
491    #[test]
492    fn closest_node_to_gpu_rejects_bad_node() {
493        let topo = NumaTopology::two_node(20);
494        assert_eq!(closest_node_to_gpu(&topo, 9), Err(CudaError::InvalidValue));
495    }
496
497    #[test]
498    fn numa_buffer_on_node_valid() {
499        let topo = NumaTopology::two_node(20);
500        let buf = NumaBuffer::on_node(&topo, 1, 8192).expect("alloc");
501        assert_eq!(buf.node(), 1);
502        assert_eq!(buf.byte_size(), 8192);
503    }
504
505    #[test]
506    fn numa_buffer_rejects_zero_bytes() {
507        let topo = NumaTopology::two_node(20);
508        assert_eq!(
509            NumaBuffer::on_node(&topo, 0, 0),
510            Err(CudaError::InvalidValue)
511        );
512    }
513
514    #[test]
515    fn numa_buffer_rejects_bad_node() {
516        let topo = NumaTopology::two_node(20);
517        assert_eq!(
518            NumaBuffer::on_node(&topo, 7, 4096),
519            Err(CudaError::InvalidValue)
520        );
521    }
522
523    #[test]
524    fn numa_buffer_closest_to_gpu() {
525        let topo = NumaTopology::two_node(20);
526        let buf = NumaBuffer::closest_to_gpu(&topo, 1, 4096).expect("alloc");
527        assert_eq!(buf.node(), 1);
528    }
529
530    #[test]
531    fn numa_buffer_locality_and_distance() {
532        let topo = NumaTopology::two_node(20);
533        let buf = NumaBuffer::on_node(&topo, 1, 4096).expect("alloc");
534        assert!(buf.is_local_to(1));
535        assert!(!buf.is_local_to(0));
536        // Access from node 1 (local) -> 10; from node 0 (remote) -> 20.
537        assert_eq!(buf.access_distance(&topo, 1), Some(10));
538        assert_eq!(buf.access_distance(&topo, 0), Some(20));
539        assert_eq!(buf.access_distance(&topo, 9), None);
540    }
541
542    #[test]
543    fn numa_buffer_display() {
544        let topo = NumaTopology::two_node(20);
545        let buf = NumaBuffer::on_node(&topo, 1, 4096).expect("alloc");
546        let s = format!("{buf}");
547        assert!(s.contains("node=1"));
548        assert!(s.contains("4096"));
549    }
550
551    #[test]
552    fn tracker_records_and_releases() {
553        let topo = NumaTopology::two_node(20);
554        let mut tracker = NumaAllocTracker::new(&topo);
555        let a = NumaBuffer::on_node(&topo, 0, 1000).expect("a");
556        let b = NumaBuffer::on_node(&topo, 1, 2000).expect("b");
557        let c = NumaBuffer::on_node(&topo, 0, 500).expect("c");
558
559        tracker.record(&a).expect("rec a");
560        tracker.record(&b).expect("rec b");
561        tracker.record(&c).expect("rec c");
562
563        assert_eq!(tracker.bytes_on_node(0), Some(1500));
564        assert_eq!(tracker.bytes_on_node(1), Some(2000));
565        assert_eq!(tracker.total_bytes(), 3500);
566
567        tracker.release(&a).expect("rel a");
568        assert_eq!(tracker.bytes_on_node(0), Some(500));
569        assert_eq!(tracker.total_bytes(), 2500);
570    }
571
572    #[test]
573    fn tracker_bytes_on_bad_node_is_none() {
574        let topo = NumaTopology::two_node(20);
575        let tracker = NumaAllocTracker::new(&topo);
576        assert_eq!(tracker.bytes_on_node(9), None);
577    }
578
579    #[test]
580    fn tracker_least_loaded_node() {
581        let topo = NumaTopology::uniform(3, 20).expect("topo");
582        let mut tracker = NumaAllocTracker::new(&topo);
583        let a = NumaBuffer::on_node(&topo, 0, 5000).expect("a");
584        let b = NumaBuffer::on_node(&topo, 1, 1000).expect("b");
585        tracker.record(&a).expect("rec a");
586        tracker.record(&b).expect("rec b");
587        // Node 2 has 0 bytes -> least loaded.
588        assert_eq!(tracker.least_loaded_node(), Some(2));
589    }
590
591    #[test]
592    fn tracker_least_loaded_ties_pick_lowest_index() {
593        let topo = NumaTopology::two_node(20);
594        let tracker = NumaAllocTracker::new(&topo);
595        // Both nodes at 0 -> lowest index 0.
596        assert_eq!(tracker.least_loaded_node(), Some(0));
597    }
598
599    #[test]
600    fn tracker_release_saturates_at_zero() {
601        let topo = NumaTopology::single_node();
602        let mut tracker = NumaAllocTracker::new(&topo);
603        let buf = NumaBuffer::on_node(&topo, 0, 100).expect("buf");
604        // Release without record should not underflow.
605        tracker.release(&buf).expect("rel");
606        assert_eq!(tracker.bytes_on_node(0), Some(0));
607    }
608}