Skip to main content

dynamo_memory/numa/
mod.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! NUMA-aware memory allocation utilities.
5//!
6//! This module provides utilities for NUMA-aware memory allocation, which is critical
7//! for optimal performance on multi-socket systems with GPUs. Memory allocated on the
8//! NUMA node closest to the target GPU has significantly lower access latency.
9//!
10//! ## Architecture
11//!
12//! - [`NumaNode`]: Represents a NUMA node ID
13//! - [`topology`]: Reads CPU-to-NUMA mapping from `/sys/devices/system/node`
14//! - [`worker_pool`]: Dedicated worker threads pinned to specific NUMA nodes
15//!
16//! ## Usage
17//!
18//! NUMA optimization is enabled by default. To disable it:
19//! ```bash
20//! export DYN_MEMORY_DISABLE_NUMA=1
21//! ```
22//!
23//! When enabled, pinned memory allocations are routed through NUMA workers
24//! that are pinned to the target GPU's NUMA node, ensuring first-touch policy
25//! places pages on the correct node. If the GPU's NUMA node cannot be
26//! determined, allocation falls back to the non-NUMA path transparently.
27
28pub(crate) mod nvml;
29pub mod topology;
30pub mod worker_pool;
31
32use cudarc::driver::{result::device as cuda_device, sys as cuda_sys};
33use nix::libc;
34use serde::{Deserialize, Serialize};
35use std::collections::HashMap;
36use std::sync::{Mutex, OnceLock};
37use std::{fs, mem, process::Command};
38
39/// Cache for GPU PCI address → NUMA node lookups.
40/// The mapping never changes at runtime, so we cache results (including negative
41/// lookups) to avoid repeated sysfs reads and nvidia-smi subprocesses.
42static NUMA_NODE_CACHE: OnceLock<Mutex<HashMap<String, Option<NumaNode>>>> = OnceLock::new();
43
44/// Check if NUMA optimization is disabled via environment variable.
45///
46/// NUMA-aware allocation is enabled by default. Set `DYN_MEMORY_DISABLE_NUMA=1`
47/// (or any truthy value) to disable it.
48pub fn is_numa_enabled() -> bool {
49    !crate::env_is_truthy("DYN_MEMORY_DISABLE_NUMA")
50}
51
52/// Convenience inverse of [`is_numa_enabled`].
53pub fn is_numa_disabled() -> bool {
54    !is_numa_enabled()
55}
56
57/// Represents a NUMA node identifier.
58///
59/// NUMA nodes are typically numbered 0, 1, 2, etc. corresponding to physical
60/// CPU sockets. Use [`NumaNode::UNKNOWN`] when the node cannot be determined.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
62pub struct NumaNode(pub u32);
63
64impl NumaNode {
65    /// Sentinel value for unknown NUMA node.
66    pub const UNKNOWN: NumaNode = NumaNode(u32::MAX);
67
68    /// Returns true if this represents an unknown NUMA node.
69    pub fn is_unknown(&self) -> bool {
70        self.0 == u32::MAX
71    }
72}
73
74impl std::fmt::Display for NumaNode {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        if self.is_unknown() {
77            write!(f, "UNKNOWN")
78        } else {
79            write!(f, "NumaNode({})", self.0)
80        }
81    }
82}
83
84/// Get the current CPU's NUMA node.
85///
86/// Uses the Linux `getcpu` syscall to determine which NUMA node the current CPU belongs to.
87/// Returns [`NumaNode::UNKNOWN`] if the syscall fails.
88pub fn get_current_cpu_numa_node() -> NumaNode {
89    unsafe {
90        let mut cpu: libc::c_uint = 0;
91        let mut node: libc::c_uint = 0;
92
93        // getcpu syscall: int getcpu(unsigned *cpu, unsigned *node, struct getcpu_cache *tcache);
94        let result = libc::syscall(
95            libc::SYS_getcpu,
96            &mut cpu,
97            &mut node,
98            std::ptr::null_mut::<libc::c_void>(),
99        );
100        if result == 0 {
101            NumaNode(node)
102        } else {
103            NumaNode::UNKNOWN
104        }
105    }
106}
107
108/// Read the NUMA node for a PCI device from sysfs.
109///
110/// Reads `/sys/bus/pci/devices/<pci_address>/numa_node`. Returns `None` if the
111/// file doesn't exist, can't be read, or contains `-1` (no NUMA affinity).
112fn read_numa_node_from_sysfs(pci_address: &str) -> Option<NumaNode> {
113    let path = format!("/sys/bus/pci/devices/{}/numa_node", pci_address);
114    let content = fs::read_to_string(&path).ok()?;
115    let node: i32 = content.trim().parse().ok()?;
116    if node < 0 {
117        // -1 means no NUMA affinity info available
118        None
119    } else {
120        Some(NumaNode(node as u32))
121    }
122}
123
124/// Fallback: query NUMA node from nvidia-smi using PCI bus address.
125///
126/// Uses the PCI BDF address (not env-var-based device index) so it is
127/// correct regardless of `CUDA_VISIBLE_DEVICES` remapping.
128fn get_numa_node_from_nvidia_smi(pci_address: &str) -> Option<NumaNode> {
129    let output = Command::new("nvidia-smi")
130        .args(["topo", "--get-numa-id-of-nearby-cpu", "-i", pci_address])
131        .output()
132        .ok()?;
133
134    if !output.status.success() {
135        return None;
136    }
137
138    let stdout = std::str::from_utf8(&output.stdout).ok()?;
139    let line = stdout.lines().next()?;
140    let numa_str = line.split(':').nth(1)?;
141    let node: u32 = numa_str.trim().parse().ok()?;
142    Some(NumaNode(node))
143}
144
145/// Get NUMA node for a GPU device.
146///
147/// Queries the PCI bus address from the CUDA driver API, then reads the NUMA
148/// node from sysfs. Falls back to nvidia-smi with the PCI address. Returns
149/// `None` if the NUMA node cannot be determined, signaling the caller to skip
150/// NUMA-aware allocation entirely rather than guessing wrong.
151///
152/// `CUDA_VISIBLE_DEVICES` is handled transparently because `CudaContext::new(ordinal)`
153/// operates on the process-local device index.
154///
155/// # Arguments
156/// * `device_id` - CUDA device index (0, 1, 2, ...) as seen by the process
157///
158/// # Returns
159/// The NUMA node closest to the specified GPU, or `None` if it cannot be determined.
160pub fn get_device_numa_node(device_id: u32) -> Option<NumaNode> {
161    // Step 1: Get PCI bus address from CUDA driver
162    let pci_address = match get_pci_bus_address_from_cuda(device_id) {
163        Some(addr) => addr,
164        None => {
165            tracing::warn!(
166                "Failed to get PCI address from CUDA for device {}, skipping NUMA optimization",
167                device_id
168            );
169            return None;
170        }
171    };
172
173    // Step 2: Check cache (includes negative lookups)
174    let cache = NUMA_NODE_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
175    {
176        let guard = cache.lock().unwrap();
177        if let Some(cached) = guard.get(&pci_address) {
178            return *cached;
179        }
180    }
181
182    // Step 3: Read NUMA node from sysfs
183    let result = read_numa_node_from_sysfs(&pci_address)
184        .or_else(|| get_numa_node_from_nvidia_smi(&pci_address));
185
186    match result {
187        Some(node) => {
188            tracing::trace!(
189                "GPU {} (PCI {}) on NUMA node {}",
190                device_id,
191                pci_address,
192                node.0
193            );
194        }
195        None => {
196            tracing::warn!(
197                "Could not determine NUMA node for GPU {} (PCI {}), skipping NUMA optimization",
198                device_id,
199                pci_address
200            );
201        }
202    }
203
204    // Cache result (including None for negative lookups)
205    cache.lock().unwrap().insert(pci_address, result);
206    result
207}
208
209/// Pin the current thread to a specific NUMA node's CPUs.
210///
211/// This sets the CPU affinity for the calling thread to only run on CPUs
212/// belonging to the specified NUMA node. This is critical for ensuring
213/// that memory allocations follow the first-touch policy on the correct node.
214///
215/// # Arguments
216/// * `node` - The NUMA node to pin the thread to
217///
218/// # Errors
219/// Returns an error if:
220/// - NUMA topology cannot be read
221/// - No CPUs are found for the specified node
222/// - The `sched_setaffinity` syscall fails
223pub fn pin_thread_to_numa_node(node: NumaNode) -> Result<(), String> {
224    let topology =
225        topology::get_numa_topology().map_err(|e| format!("Can not get NUMA topology: {}", e))?;
226
227    let cpus = topology
228        .cpus_for_node(node.0)
229        .ok_or_else(|| format!("No CPUs found for NUMA node {}", node.0))?;
230
231    if cpus.is_empty() {
232        return Err(format!("No CPUs found for NUMA node {}", node.0));
233    }
234
235    unsafe {
236        let mut cpu_set: libc::cpu_set_t = mem::zeroed();
237
238        for cpu in cpus {
239            libc::CPU_SET(*cpu, &mut cpu_set);
240        }
241
242        let result = libc::sched_setaffinity(
243            0, // current thread
244            mem::size_of::<libc::cpu_set_t>(),
245            &cpu_set,
246        );
247
248        if result != 0 {
249            let err = std::io::Error::last_os_error();
250            return Err(format!("Failed to set CPU affinity: {}", err));
251        }
252    }
253
254    Ok(())
255}
256
257/// Get PCI bus address for a CUDA device via the CUDA driver API.
258///
259/// Returns a normalized PCI address string like "0000:3b:00.0".
260/// The device_id here is a CUDA ordinal (affected by CUDA_VISIBLE_DEVICES).
261fn get_pci_bus_address_from_cuda(device_id: u32) -> Option<String> {
262    // SAFETY: We're calling CUDA driver API functions with valid device ordinals.
263    // cuDeviceGet and get_attribute are safe as long as CUDA is initialized
264    // (which CudaContext::new handles).
265    unsafe {
266        let mut dev = std::mem::MaybeUninit::uninit();
267        if cuda_sys::cuDeviceGet(dev.as_mut_ptr(), device_id as i32)
268            .result()
269            .is_err()
270        {
271            return None;
272        }
273        let dev = dev.assume_init();
274
275        let domain = cuda_device::get_attribute(
276            dev,
277            cuda_sys::CUdevice_attribute::CU_DEVICE_ATTRIBUTE_PCI_DOMAIN_ID,
278        )
279        .ok()?;
280        let bus = cuda_device::get_attribute(
281            dev,
282            cuda_sys::CUdevice_attribute::CU_DEVICE_ATTRIBUTE_PCI_BUS_ID,
283        )
284        .ok()?;
285        let device = cuda_device::get_attribute(
286            dev,
287            cuda_sys::CUdevice_attribute::CU_DEVICE_ATTRIBUTE_PCI_DEVICE_ID,
288        )
289        .ok()?;
290
291        Some(format!("{:04x}:{:02x}:{:02x}.0", domain, bus, device))
292    }
293}
294
295/// GPU info with PCI address and NUMA node, used for CPU set subdivision.
296#[derive(Debug, Clone)]
297struct GpuTopoInfo {
298    pci_address: String,
299    numa_node: Option<u32>,
300}
301
302/// Enumerate all GPUs visible to CUDA with their PCI addresses and NUMA nodes.
303fn enumerate_cuda_gpus() -> Vec<GpuTopoInfo> {
304    let count = match cuda_device::get_count() {
305        Ok(c) => c,
306        Err(_) => return Vec::new(),
307    };
308
309    (0..count as u32)
310        .filter_map(|i| {
311            let pci = get_pci_bus_address_from_cuda(i)?;
312            let numa = read_numa_node_from_sysfs(&pci).map(|n| n.0);
313            Some(GpuTopoInfo {
314                pci_address: pci,
315                numa_node: numa,
316            })
317        })
318        .collect()
319}
320
321/// Enumerate all GPUs on the system, preferring NVML (sees all GPUs)
322/// over CUDA driver (only sees CUDA_VISIBLE_DEVICES).
323fn enumerate_all_gpus() -> Vec<GpuTopoInfo> {
324    // Try NVML first — it sees all GPUs regardless of CUDA_VISIBLE_DEVICES
325    if let Some(nvml) = nvml::try_nvml() {
326        let nvml_gpus = nvml.enumerate_gpus();
327        if !nvml_gpus.is_empty() {
328            tracing::debug!(
329                "NVML enumerated {} GPUs (ignoring CUDA_VISIBLE_DEVICES)",
330                nvml_gpus.len()
331            );
332            return nvml_gpus
333                .into_iter()
334                .map(|g| {
335                    let numa = read_numa_node_from_sysfs(&g.pci_address).map(|n| n.0);
336                    GpuTopoInfo {
337                        pci_address: g.pci_address,
338                        numa_node: numa,
339                    }
340                })
341                .collect();
342        }
343    }
344
345    // Fallback: enumerate via CUDA driver (may miss hidden devices)
346    tracing::debug!("Falling back to CUDA driver GPU enumeration");
347    enumerate_cuda_gpus()
348}
349
350/// Cached CPU set results per CUDA device ordinal.
351static DEVICE_CPU_SETS: OnceLock<HashMap<u32, Option<Vec<usize>>>> = OnceLock::new();
352
353/// Get a deterministic CPU subset for a CUDA device, subdivided among ALL GPUs
354/// sharing the same NUMA node (including those hidden by CUDA_VISIBLE_DEVICES).
355///
356/// # Algorithm
357/// 1. Get PCI address + NUMA node for target device (CUDA driver API)
358/// 2. Enumerate ALL GPUs on the system:
359///    - Try NVML first (sees all GPUs, ignores CUDA_VISIBLE_DEVICES)
360///    - Fall back to CUDA driver API (only sees visible devices)
361/// 3. For each GPU, get its NUMA node via sysfs (PCI address → /sys/.../numa_node)
362/// 4. Group GPUs by NUMA node
363/// 5. Sort by PCI address within each group (deterministic)
364/// 6. Get full CPU set for the node via topology
365/// 7. Divide into N equal slices (N = GPUs on same node)
366/// 8. Return the slice for the target device's position
367///
368/// # Example
369/// System: 8 GPUs, 2 NUMA nodes, 4 GPUs per node.
370/// CUDA_VISIBLE_DEVICES=0,1 (only 2 visible).
371/// NVML sees all 8 → correctly subdivides into 4 slices per node.
372///
373/// Returns None if NUMA node can't be determined.
374pub fn get_device_cpu_set(device_id: u32) -> Option<Vec<usize>> {
375    DEVICE_CPU_SETS
376        .get_or_init(compute_all_device_cpu_sets)
377        .get(&device_id)
378        .cloned()
379        .flatten()
380}
381
382fn compute_all_device_cpu_sets() -> HashMap<u32, Option<Vec<usize>>> {
383    let topology = match topology::get_numa_topology() {
384        Ok(t) => t,
385        Err(e) => {
386            tracing::warn!("Cannot subdivide CPU sets: {e}");
387            return HashMap::new();
388        }
389    };
390
391    // Get the target device's PCI address and NUMA node
392    let cuda_count = cuda_device::get_count().unwrap_or(0);
393    if cuda_count == 0 {
394        return HashMap::new();
395    }
396
397    // Build info for each visible CUDA device
398    let mut cuda_devices: Vec<(u32, String, Option<u32>)> = Vec::new();
399    for i in 0..cuda_count as u32 {
400        if let Some(pci) = get_pci_bus_address_from_cuda(i) {
401            let numa = read_numa_node_from_sysfs(&pci).map(|n| n.0);
402            cuda_devices.push((i, pci, numa));
403        }
404    }
405
406    // Enumerate ALL GPUs on the system (NVML preferred)
407    let all_gpus = enumerate_all_gpus();
408
409    // Group all GPUs by NUMA node
410    let mut node_groups: HashMap<u32, Vec<String>> = HashMap::new();
411    for gpu in &all_gpus {
412        if let Some(node) = gpu.numa_node {
413            node_groups
414                .entry(node)
415                .or_default()
416                .push(gpu.pci_address.clone());
417        }
418    }
419
420    // Sort each group by PCI address for deterministic ordering
421    for group in node_groups.values_mut() {
422        group.sort();
423    }
424
425    // For each CUDA device, find its position in its NUMA group and subdivide
426    let mut results = HashMap::new();
427    for (device_id, pci_addr, numa_node) in &cuda_devices {
428        let cpu_set = numa_node.and_then(|node| {
429            let group = node_groups.get(&node)?;
430            let position = group.iter().position(|addr| addr == pci_addr)?;
431            let all_cpus = topology.cpus_for_node(node)?;
432
433            if all_cpus.is_empty() || group.is_empty() {
434                return None;
435            }
436
437            // Divide CPUs into N equal slices
438            let n = group.len();
439            let chunk_size = all_cpus.len() / n;
440            if chunk_size == 0 {
441                // More GPUs than CPUs on this node — give all CPUs to everyone
442                return Some(all_cpus.to_vec());
443            }
444
445            let start = position * chunk_size;
446            let end = if position == n - 1 {
447                all_cpus.len() // last slice gets remainder
448            } else {
449                start + chunk_size
450            };
451
452            Some(all_cpus[start..end].to_vec())
453        });
454
455        results.insert(*device_id, cpu_set);
456    }
457
458    results
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn test_numa_node_equality() {
467        let node0a = NumaNode(0);
468        let node0b = NumaNode(0);
469        let node1 = NumaNode(1);
470
471        assert_eq!(node0a, node0b);
472        assert_ne!(node0a, node1);
473    }
474
475    #[test]
476    fn test_numa_node_unknown() {
477        let unknown = NumaNode::UNKNOWN;
478        assert!(unknown.is_unknown());
479        assert_eq!(unknown.0, u32::MAX);
480
481        let valid = NumaNode(0);
482        assert!(!valid.is_unknown());
483    }
484
485    #[test]
486    fn test_numa_node_display() {
487        assert_eq!(format!("{}", NumaNode(0)), "NumaNode(0)");
488        assert_eq!(format!("{}", NumaNode(7)), "NumaNode(7)");
489        assert_eq!(format!("{}", NumaNode::UNKNOWN), "UNKNOWN");
490    }
491
492    #[test]
493    fn test_numa_node_serialization() {
494        let node = NumaNode(1);
495        let json = serde_json::to_string(&node).unwrap();
496        let deserialized: NumaNode = serde_json::from_str(&json).unwrap();
497        assert_eq!(node, deserialized);
498    }
499
500    #[test]
501    fn test_get_current_cpu_numa_node() {
502        let node = get_current_cpu_numa_node();
503        if !node.is_unknown() {
504            assert!(node.0 < 8, "NUMA node {} seems unreasonably high", node.0);
505        }
506    }
507
508    #[test]
509    fn test_numa_node_hash() {
510        use std::collections::HashMap;
511
512        let mut map = HashMap::new();
513        map.insert(NumaNode(0), "node0");
514        map.insert(NumaNode(1), "node1");
515
516        assert_eq!(map.get(&NumaNode(0)), Some(&"node0"));
517        assert_eq!(map.get(&NumaNode(1)), Some(&"node1"));
518        assert_eq!(map.get(&NumaNode(2)), None);
519    }
520
521    #[test]
522    fn test_numa_node_copy_clone() {
523        let node1 = NumaNode(5);
524        let node2 = node1;
525        let node3 = node1;
526
527        assert_eq!(node1, node2);
528        assert_eq!(node1, node3);
529        assert_eq!(node2, node3);
530    }
531
532    #[test]
533    fn test_read_numa_node_from_sysfs_nonexistent() {
534        assert!(read_numa_node_from_sysfs("ffff:ff:ff.0").is_none());
535    }
536}
537
538#[cfg(all(test, feature = "testing-cuda"))]
539mod cuda_tests {
540    use super::*;
541
542    #[test]
543    fn test_get_pci_bus_address_from_cuda() {
544        let addr = get_pci_bus_address_from_cuda(0).expect("should get PCI address for GPU 0");
545        // Validate BDF format: DDDD:BB:DD.0
546        let parts: Vec<&str> = addr.split(':').collect();
547        assert_eq!(
548            parts.len(),
549            3,
550            "PCI address should have 3 colon-separated parts: {}",
551            addr
552        );
553        assert_eq!(parts[0].len(), 4, "domain should be 4 hex chars: {}", addr);
554        assert!(parts[2].ends_with(".0"), "should end with .0: {}", addr);
555        println!("GPU 0 PCI address: {}", addr);
556    }
557
558    #[test]
559    fn test_read_numa_node_from_sysfs_real_gpu() {
560        let addr = get_pci_bus_address_from_cuda(0).expect("should get PCI address for GPU 0");
561        if let Some(node) = read_numa_node_from_sysfs(&addr) {
562            assert!(node.0 < 16, "NUMA node {} seems unreasonably high", node.0);
563            println!("GPU 0 (PCI {}) sysfs NUMA node: {}", addr, node.0);
564        } else {
565            println!(
566                "GPU 0 (PCI {}) has no sysfs NUMA info (single-socket?)",
567                addr
568            );
569        }
570    }
571
572    #[test]
573    fn test_get_device_numa_node_returns_some_or_none() {
574        let result = get_device_numa_node(0);
575        match result {
576            Some(node) => {
577                assert!(node.0 < 16, "NUMA node {} seems unreasonably high", node.0);
578                assert!(
579                    !node.is_unknown(),
580                    "should never return UNKNOWN inside Some"
581                );
582                println!("GPU 0 detected on NUMA node: {}", node.0);
583            }
584            None => {
585                println!("GPU 0 has no determinable NUMA node (single-socket or no sysfs info)");
586            }
587        }
588    }
589}