Skip to main content

monarch_rdma/
device_selection.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! PCI topology parsing and device discovery utilities for RDMA device selection.
10//!
11//! ibverbs-specific selection logic lives in [`crate::backend::ibverbs::device_selection`].
12
13use std::fmt;
14use std::fs;
15use std::path::Path;
16use std::path::PathBuf;
17use std::sync::LazyLock;
18
19use anyhow::Error;
20use anyhow::Result;
21use dashmap::DashSet;
22use rdmaxcel_sys::CUresult;
23
24use crate::local_memory::is_device_ptr;
25
26fn cuda_error_string(rc: CUresult) -> String {
27    // The lookup below goes through the same driver wrapper as every other call,
28    // so it cannot name the one code that means there is no driver to ask: it
29    // would fail the same way and leave `s` null.
30    if rc == rdmaxcel_sys::CUDA_ERROR_NOT_INITIALIZED {
31        return "CUDA_ERROR_NOT_INITIALIZED".to_owned();
32    }
33    let mut s: *const std::os::raw::c_char = std::ptr::null();
34    // SAFETY: `&mut s` is a valid, properly aligned, writable pointer
35    // to a `const char*`, valid for the duration of the call.
36    unsafe { rdmaxcel_sys::rdmaxcel_cuGetErrorString(rc, &mut s) };
37    if s.is_null() {
38        format!("unknown error code ({rc})")
39    } else {
40        // SAFETY: `s` is non-null (checked above) and points to a
41        // null-terminated string with static lifetime, as guaranteed
42        // by `cuGetErrorString`.
43        unsafe { std::ffi::CStr::from_ptr(s) }
44            .to_string_lossy()
45            .into_owned()
46    }
47}
48
49/// Number of CUDA devices visible to this process.
50///
51/// Never loads libcuda and never calls `cuInit`. The `rdmaxcel_cu*` wrappers
52/// adopt an already-resident driver via `dlopen(RTLD_NOLOAD)` and otherwise
53/// report `CUDA_ERROR_NOT_INITIALIZED`, so a process that has not touched CUDA
54/// pays only a failed symbol lookup and never gains a CUDA context -- and gets
55/// an error here.
56pub fn cuda_device_count() -> Result<i32> {
57    let mut count: i32 = 0;
58    // SAFETY: FFI writes one `i32` through the out-pointer and has no other
59    // effect; on a non-success status `count` is left unread.
60    let rc = unsafe { rdmaxcel_sys::rdmaxcel_cuDeviceGetCount(&mut count) };
61    anyhow::ensure!(
62        rc == rdmaxcel_sys::CUDA_SUCCESS,
63        "cuDeviceGetCount failed: {}",
64        cuda_error_string(rc),
65    );
66    Ok(count)
67}
68
69/// One `cuDeviceGetAttribute` query on `device`.
70fn cuda_device_attribute(attr: rdmaxcel_sys::CUdevice_attribute, device: i32) -> Result<i32> {
71    let mut value: i32 = 0;
72    // SAFETY: FFI writes one `i32` through the out-pointer and has no other effect.
73    let rc = unsafe { rdmaxcel_sys::rdmaxcel_cuDeviceGetAttribute(&mut value, attr, device) };
74    anyhow::ensure!(
75        rc == rdmaxcel_sys::CUDA_SUCCESS,
76        "cuDeviceGetAttribute({attr}) failed on CUDA device {device}: {}",
77        cuda_error_string(rc),
78    );
79    Ok(value)
80}
81
82/// PCI address of the CUDA device with runtime ordinal `ordinal`, from the CUDA
83/// driver.
84///
85/// `ordinal` is a *runtime* ordinal: the numbering CUDA exposes after applying
86/// `CUDA_VISIBLE_DEVICES`, as reported by `CU_POINTER_ATTRIBUTE_DEVICE_ORDINAL`
87/// or `torch.cuda.current_device()`. Asking the driver rather than
88/// reconstructing that numbering from `/proc/driver/nvidia` keeps it correct for
89/// every `CUDA_VISIBLE_DEVICES` form (indices, `GPU-<uuid>`, `MIG-<uuid>`), for
90/// any `CUDA_DEVICE_ORDER`, and inside a container exposing a GPU subset.
91///
92/// Errors when the CUDA driver is not initialized in this process, or when
93/// `ordinal` is not visible.
94///
95/// Backend-agnostic: under ROCm rdmaxcel's wrappers resolve to `hipDeviceGet` /
96/// `hipGetDeviceCount` / `hipDeviceGetAttribute`, and `rocm_compat` aliases the
97/// `CU_DEVICE_ATTRIBUTE_PCI_*` constants to their HIP equivalents.
98/// TODO(slurye): validate that this actually works on ROCm.
99pub fn cuda_pci_address(ordinal: u32) -> Result<PCIAddress> {
100    let count = cuda_device_count()?;
101    anyhow::ensure!(
102        i64::from(ordinal) < i64::from(count),
103        "CUDA device {ordinal} is not visible to this process ({count} visible)"
104    );
105
106    let mut device: rdmaxcel_sys::CUdevice = 0;
107    // SAFETY: FFI writes one `CUdevice` through the out-pointer; `ordinal` is in
108    // range per the check above.
109    let rc = unsafe { rdmaxcel_sys::rdmaxcel_cuDeviceGet(&mut device, ordinal as i32) };
110    anyhow::ensure!(
111        rc == rdmaxcel_sys::CUDA_SUCCESS,
112        "cuDeviceGet failed for CUDA device {ordinal}: {}",
113        cuda_error_string(rc),
114    );
115
116    let domain = cuda_device_attribute(rdmaxcel_sys::CU_DEVICE_ATTRIBUTE_PCI_DOMAIN_ID, device)?;
117    let bus = cuda_device_attribute(rdmaxcel_sys::CU_DEVICE_ATTRIBUTE_PCI_BUS_ID, device)?;
118    let slot = cuda_device_attribute(rdmaxcel_sys::CU_DEVICE_ATTRIBUTE_PCI_DEVICE_ID, device)?;
119    Ok(PCIAddress {
120        domain: u16::try_from(domain)?,
121        bus: u8::try_from(bus)?,
122        device: u8::try_from(slot)?,
123        // Assume GPUs are always PCI function 0
124        function: 0,
125    })
126}
127
128/// A PCI address, e.g. `0000:07:00.0`, as found under
129/// `/sys/bus/pci/devices`.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
131pub struct PCIAddress {
132    pub domain: u16,
133    pub bus: u8,
134    pub device: u8,
135    pub function: u8,
136}
137
138impl PCIAddress {
139    /// Parse a `dddd:bb:dd.f` lowercase-hex PCI address.
140    pub fn parse(s: &str) -> Option<Self> {
141        let (domain, rest) = s.split_once(':')?;
142        let (bus, rest) = rest.split_once(':')?;
143        let (device, function) = rest.split_once('.')?;
144        Some(Self {
145            domain: u16::from_str_radix(domain, 16).ok()?,
146            bus: u8::from_str_radix(bus, 16).ok()?,
147            device: u8::from_str_radix(device, 16).ok()?,
148            function: u8::from_str_radix(function, 16).ok()?,
149        })
150    }
151
152    /// This device's sysfs directory, `/sys/bus/pci/devices/<bdf>`.
153    pub fn sysfs_path(&self) -> PathBuf {
154        Path::new("/sys/bus/pci/devices").join(self.to_string())
155    }
156}
157
158impl fmt::Display for PCIAddress {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        write!(
161            f,
162            "{:04x}:{:02x}:{:02x}.{:x}",
163            self.domain, self.bus, self.device, self.function
164        )
165    }
166}
167
168/// A source of memory for a transfer. A `None` index means "any device of
169/// this kind": the location is then ranked against the best of all CPU
170/// nodes or all GPUs.
171#[derive(
172    Debug,
173    Clone,
174    Copy,
175    PartialEq,
176    Eq,
177    Hash,
178    serde::Serialize,
179    serde::Deserialize
180)]
181pub enum MemoryLocation {
182    /// CPU memory on the given NUMA node, or any CPU node if `None`.
183    Cpu(Option<u32>),
184    /// GPU memory on the given CUDA device ordinal, or any GPU if `None`.
185    Gpu(Option<u32>),
186}
187
188impl MemoryLocation {
189    /// Where the memory at `addr` lives.
190    ///
191    /// A device pointer resolves to the CUDA ordinal that owns it. Host memory
192    /// is [`Self::Cpu(None)`]: the NUMA node backing the allocation is not
193    /// resolved.
194    ///
195    /// Errors when `addr` is device memory whose owning ordinal cannot be
196    /// queried.
197    pub fn from_addr(addr: usize) -> Result<Self> {
198        if !is_device_ptr(addr) {
199            return Ok(Self::Cpu(None));
200        }
201        let mut ordinal: i32 = -1;
202        // SAFETY: FFI writes one `i32` through the out-pointer; `addr` is passed
203        // by value as an opaque device address and never dereferenced.
204        let rc = unsafe {
205            rdmaxcel_sys::rdmaxcel_cuPointerGetAttribute(
206                &mut ordinal as *mut _ as *mut std::ffi::c_void,
207                rdmaxcel_sys::CU_POINTER_ATTRIBUTE_DEVICE_ORDINAL,
208                addr as rdmaxcel_sys::CUdeviceptr,
209            )
210        };
211        anyhow::ensure!(
212            rc == rdmaxcel_sys::CUDA_SUCCESS,
213            "cuPointerGetAttribute(DEVICE_ORDINAL) failed for device memory at {addr:#x}: {}",
214            cuda_error_string(rc),
215        );
216        Ok(Self::Gpu(Some(
217            u32::try_from(ordinal).expect("CUDA device ordinal should be non-negative"),
218        )))
219    }
220}
221
222/// Locality of a path between two PCI endpoints, ordered best to worst.
223/// A path's type is its worst (least local) segment.
224#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
225pub enum PathType {
226    /// Within a single PCIe switch.
227    Pix,
228    /// Across multiple PCIe switches under one host bridge.
229    Pxb,
230    /// Up through the CPU host bridge, within one NUMA node.
231    Phb,
232    /// Across the inter-socket / cross-NUMA interconnect.
233    Sys,
234    /// No path between the endpoints.
235    Dis,
236}
237
238/// A classified path between two PCI endpoints: its [`PathType`] and the
239/// bottleneck (minimum) PCIe link bandwidth along it, in MB/s.
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub struct PciPath {
242    pub path_type: PathType,
243    pub bottleneck_mbytes_per_sec: u32,
244}
245
246impl PciPath {
247    /// Whether this path is preferable to `other`: more local (lower
248    /// [`PathType`]) wins, and among equally-local paths the higher
249    /// bottleneck bandwidth wins.
250    pub fn is_better_than(&self, other: &PciPath) -> bool {
251        (self.path_type, other.bottleneck_mbytes_per_sec)
252            < (other.path_type, self.bottleneck_mbytes_per_sec)
253    }
254}
255
256/// The [`PathType`] and bottleneck bandwidth of the PCIe path between two
257/// PCI endpoints.
258///
259/// Walks each endpoint's sysfs ancestor chain toward the root complex,
260/// finds their lowest common ancestor, and takes the minimum link
261/// bandwidth along the way. When the endpoints share no PCIe ancestor the
262/// path runs through the CPU: [`PathType::Phb`] when both sit on the same
263/// NUMA node, [`PathType::Sys`] otherwise — including when either
264/// endpoint's affinity is unknown.
265pub fn pci_path(a: &PCIAddress, b: &PCIAddress) -> PciPath {
266    classify(
267        &ancestor_chain(a),
268        numa_node(a),
269        &ancestor_chain(b),
270        numa_node(b),
271    )
272}
273
274/// Path from CPU memory to the device at `addr`. With a specific NUMA
275/// node, the device is [`PathType::Phb`] when it sits on that node and
276/// [`PathType::Sys`] otherwise; with `None` (any CPU) it is judged against
277/// its own node and so is always [`PathType::Phb`]. Bandwidth is the
278/// device's PCIe chain bottleneck.
279pub fn cpu_path(addr: &PCIAddress, numa: Option<u32>) -> PciPath {
280    let bottleneck_mbytes_per_sec = min_link_mbytes_per_sec(&ancestor_chain(addr));
281    let path_type = match numa {
282        Some(node) if numa_node(addr) != Some(node) => PathType::Sys,
283        _ => PathType::Phb,
284    };
285    PciPath {
286        path_type,
287        bottleneck_mbytes_per_sec,
288    }
289}
290
291/// One node in a device's PCIe ancestor chain: its resolved sysfs path
292/// and the bandwidth (MB/s) of the PCIe link immediately upstream of it.
293struct PciHop {
294    sysfs: PathBuf,
295    link_mbytes_per_sec: u32,
296}
297
298/// Classify the path between two ancestor chains (device → root complex).
299/// Pure over its inputs, so it is unit-tested without touching sysfs.
300fn classify(a: &[PciHop], numa_a: Option<u32>, b: &[PciHop], numa_b: Option<u32>) -> PciPath {
301    if let Some((ia, ib)) = common_ancestor(a, b) {
302        // The path traverses the links upstream of each hop below the common
303        // ancestor (`a[..ia]` then `b[..ib]`). The ancestor's own upstream
304        // link runs further up the tree and is not part of the path, so it is
305        // excluded.
306        let bottleneck_mbytes_per_sec = a[..ia]
307            .iter()
308            .chain(&b[..ib])
309            .map(|h| h.link_mbytes_per_sec)
310            .min()
311            .unwrap_or(0);
312        // A PCIe switch spans two sysfs hops (its downstream and upstream
313        // ports), so meeting within two hops of the common ancestor means
314        // both devices sit under one switch; farther means multiple.
315        let single_switch = ia <= 2 && ib <= 2;
316        let path_type = if single_switch {
317            PathType::Pix
318        } else {
319            PathType::Pxb
320        };
321        return PciPath {
322            path_type,
323            bottleneck_mbytes_per_sec,
324        };
325    }
326    // No shared PCIe ancestor: the path runs through the CPU. An unknown
327    // NUMA node cannot be proven same-node, so it takes the worst reachable
328    // class instead of being reported as unreachable.
329    let bottleneck_mbytes_per_sec = min_link_mbytes_per_sec(a).min(min_link_mbytes_per_sec(b));
330    let path_type = match (numa_a, numa_b) {
331        (Some(x), Some(y)) if x == y => PathType::Phb,
332        _ => PathType::Sys,
333    };
334    PciPath {
335        path_type,
336        bottleneck_mbytes_per_sec,
337    }
338}
339
340/// Indices of the deepest sysfs path common to both chains, scanning each
341/// from its device end.
342fn common_ancestor(a: &[PciHop], b: &[PciHop]) -> Option<(usize, usize)> {
343    a.iter().enumerate().find_map(|(ia, na)| {
344        b.iter()
345            .position(|nb| nb.sysfs == na.sysfs)
346            .map(|ib| (ia, ib))
347    })
348}
349
350/// Minimum upstream link bandwidth (MB/s) across `hops`. Per-hop bandwidths
351/// are never 0 — unreadable link attributes fall back to a default — so this
352/// is 0 only for an empty `hops`, meaning a device whose sysfs ancestor chain
353/// could not be resolved at all.
354fn min_link_mbytes_per_sec(hops: &[PciHop]) -> u32 {
355    hops.iter()
356        .map(|h| h.link_mbytes_per_sec)
357        .min()
358        .unwrap_or(0)
359}
360
361/// Walk `addr`'s PCIe ancestor chain from the device up toward the root
362/// complex, resolving sysfs symlinks. Empty if the device's sysfs entry
363/// can't be resolved.
364fn ancestor_chain(addr: &PCIAddress) -> Vec<PciHop> {
365    let mut chain = Vec::new();
366    let mut current = match fs::canonicalize(addr.sysfs_path()) {
367        Ok(p) => p,
368        Err(_) => return chain,
369    };
370    loop {
371        let link_mbytes_per_sec = link_bandwidth_mbytes_per_sec(&current);
372        let parent = current.parent().map(Path::to_path_buf);
373        chain.push(PciHop {
374            sysfs: current,
375            link_mbytes_per_sec,
376        });
377        match parent {
378            Some(parent) if is_pci_bdf(&parent) => current = parent,
379            _ => break,
380        }
381    }
382    chain
383}
384
385/// Whether `path`'s final component parses as a PCI address
386/// (`dddd:bb:dd.f`), e.g. `0000:00:01.0`. The root complex (`pci0000:00`)
387/// does not, which stops the ancestor walk there.
388fn is_pci_bdf(path: &Path) -> bool {
389    path.file_name()
390        .and_then(|n| n.to_str())
391        .is_some_and(|n| PCIAddress::parse(n).is_some())
392}
393
394/// NUMA node of the device at `addr`, from `<sysfs>/numa_node`. A `-1`
395/// ("unknown") maps to `None`.
396fn numa_node(addr: &PCIAddress) -> Option<u32> {
397    let raw = fs::read_to_string(addr.sysfs_path().join("numa_node")).ok()?;
398    u32::try_from(raw.trim().parse::<i32>().ok()?).ok()
399}
400
401/// Per-lane PCIe rate (Mbit/s) assumed when `max_link_speed` is missing or
402/// unrecognized: Gen3, mirroring NCCL's `kvDictPciGen` fallback
403/// (graph/topo.cc).
404const DEFAULT_SPEED_MBITS_PER_LANE: u32 = 6000;
405
406/// Lane count assumed when `max_link_width` is missing or unparseable,
407/// mirroring NCCL's `if (width == 0) width = 16` (graph/topo.cc).
408const DEFAULT_LINK_WIDTH: u32 = 16;
409
410/// Bandwidth (MB/s) of the PCIe link immediately upstream of the device at
411/// `sysfs`, from its own `max_link_speed` / `max_link_width`.
412///
413/// Missing or unparseable attributes fall back to Gen3 x16 rather than to 0.
414/// Link bandwidths are combined with `min` along a path, so a 0 here would
415/// erase every real measurement on that path and collapse unrelated
416/// candidates into a spurious tie.
417fn link_bandwidth_mbytes_per_sec(sysfs: &Path) -> u32 {
418    let speed = read_speed_mbits_per_lane(sysfs);
419    let width = read_link_width(sysfs);
420    // `speed` is effective megabits/s per lane (PCIe line-encoding overhead
421    // is already folded into the table), so `speed * width` is the link's
422    // total megabits/s; dividing by 8 converts bits to bytes → MB/s.
423    speed.saturating_mul(width) / 8
424}
425
426/// Report, at most once per `(attr, dir)`, that a PCIe link attribute could not
427/// be used, and why. Selection still works — the bandwidth falls back to Gen3
428/// x16 — but every ranking that traverses this link then rests on an assumed
429/// value, and nothing else makes that visible.
430fn warn_unusable_link_attr(attr: &str, dir: &Path, error: &str) {
431    static WARNED: LazyLock<DashSet<(String, String)>> = LazyLock::new(DashSet::new);
432    // `insert` is true only for the first caller to claim this key, and it takes
433    // the shard lock, so exactly one of them warns.
434    if WARNED.insert((attr.to_string(), dir.to_string_lossy().into_owned())) {
435        tracing::warn!(
436            "unusable PCIe {attr} at {}: {error}; assuming Gen3 x16, so RDMA device selection may rank NICs on an assumed bandwidth",
437            dir.display()
438        );
439    }
440}
441
442/// PCIe lane count from `<dir>/max_link_width`, or [`DEFAULT_LINK_WIDTH`] if
443/// the attribute is missing or does not parse as an integer.
444fn read_link_width(dir: &Path) -> u32 {
445    let parsed = fs::read_to_string(dir.join("max_link_width"))
446        .map_err(Error::from)
447        .and_then(|raw| {
448            raw.trim()
449                .parse::<u32>()
450                .map_err(Error::from)
451                .and_then(|w| {
452                    if w == 0 {
453                        Err(anyhow::anyhow!("max_link_width must be > 0"))
454                    } else {
455                        Ok(w)
456                    }
457                })
458        });
459    match parsed {
460        Ok(width) => width,
461        Err(error) => {
462            warn_unusable_link_attr("max_link_width", dir, &format!("{error:#}"));
463            DEFAULT_LINK_WIDTH
464        }
465    }
466}
467
468/// Per-lane PCIe rate (Mbit/s) from `<dir>/max_link_speed`, or
469/// [`DEFAULT_SPEED_MBITS_PER_LANE`] if the attribute is missing or names no
470/// generation this knows.
471fn read_speed_mbits_per_lane(dir: &Path) -> u32 {
472    let parsed = fs::read_to_string(dir.join("max_link_speed"))
473        .map_err(Error::from)
474        .and_then(|raw| pcie_speed_mbits_per_lane(&raw));
475    match parsed {
476        Ok(speed) => speed,
477        Err(error) => {
478            warn_unusable_link_attr("max_link_speed", dir, &format!("{error:#}"));
479            DEFAULT_SPEED_MBITS_PER_LANE
480        }
481    }
482}
483
484/// Per-lane PCIe bandwidth (Mbit/s) for a `max_link_speed` string such as
485/// `"16 GT/s PCIe"`, with line-encoding overhead folded in. `rate * lanes
486/// / 8` gives the link's MB/s. The values mirror NCCL's `kvDictPciGen`
487/// (graph/topo.cc).
488///
489/// Errors when the rate names no generation in the table, which is what a
490/// generation newer than this list looks like. The caller substitutes a default
491/// and warns; returning one from here would make that silent.
492fn pcie_speed_mbits_per_lane(speed: &str) -> Result<u32> {
493    // Match the leading "<rate> GT/s" token; the kernel may append a
494    // trailing "PCIe" and prints either "8" or "8.0" style rates.
495    let rate = speed.split_whitespace().next().unwrap_or("");
496    match rate {
497        "2.5" => Ok(1500),          // Gen1
498        "5" | "5.0" => Ok(3000),    // Gen2
499        "8" | "8.0" => Ok(6000),    // Gen3
500        "16" | "16.0" => Ok(12000), // Gen4
501        "32" | "32.0" => Ok(24000), // Gen5
502        "64" | "64.0" => Ok(48000), // Gen6
503        other => anyhow::bail!("unrecognized PCIe rate {other:?}"),
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn test_pci_address_parse_and_display() {
513        let addr = PCIAddress::parse("0000:07:00.0").unwrap();
514        assert_eq!(
515            addr,
516            PCIAddress {
517                domain: 0,
518                bus: 7,
519                device: 0,
520                function: 0,
521            }
522        );
523        assert_eq!(addr.to_string(), "0000:07:00.0");
524        // Hex components round-trip through Display.
525        assert_eq!(
526            PCIAddress::parse("00ff:1a:1f.7").unwrap().to_string(),
527            "00ff:1a:1f.7"
528        );
529        assert_eq!(PCIAddress::parse("not-an-address"), None);
530        assert_eq!(PCIAddress::parse("0000:07:00"), None);
531    }
532
533    #[test]
534    fn test_cuda_pci_address_rejects_an_absent_ordinal() {
535        // Deterministic whether or not another test in this binary has already
536        // initialized CUDA: ordinal 4096 is never visible, so this errors either
537        // way. What matters is that the message names the cause — an
538        // uninitialized driver is the one a caller can act on.
539        let error = format!(
540            "{:#}",
541            cuda_pci_address(4096).expect_err("ordinal 4096 must not resolve")
542        );
543        assert!(
544            error.contains("CUDA_ERROR_NOT_INITIALIZED") || error.contains("not visible"),
545            "the error should name its cause, got: {error}"
546        );
547    }
548
549    #[test]
550    fn test_path_type_orders_best_to_worst() {
551        assert!(PathType::Pix < PathType::Pxb);
552        assert!(PathType::Pxb < PathType::Phb);
553        assert!(PathType::Phb < PathType::Sys);
554        assert!(PathType::Sys < PathType::Dis);
555    }
556
557    #[test]
558    fn test_pci_path_is_better_than() {
559        let pix_slow = PciPath {
560            path_type: PathType::Pix,
561            bottleneck_mbytes_per_sec: 1000,
562        };
563        let pix_fast = PciPath {
564            path_type: PathType::Pix,
565            bottleneck_mbytes_per_sec: 2000,
566        };
567        let phb_fast = PciPath {
568            path_type: PathType::Phb,
569            bottleneck_mbytes_per_sec: 9000,
570        };
571        // A more local path wins regardless of bandwidth.
572        assert!(pix_slow.is_better_than(&phb_fast));
573        assert!(!phb_fast.is_better_than(&pix_slow));
574        // Equal locality: higher bandwidth wins.
575        assert!(pix_fast.is_better_than(&pix_slow));
576        assert!(!pix_slow.is_better_than(&pix_fast));
577        // A path is not strictly better than itself.
578        assert!(!pix_fast.is_better_than(&pix_fast));
579    }
580
581    #[test]
582    fn test_pcie_speed_mbits_per_lane() {
583        let rate = |s: &str| pcie_speed_mbits_per_lane(s).expect("known generation");
584        assert_eq!(rate("2.5 GT/s PCIe"), 1500);
585        assert_eq!(rate("5 GT/s"), 3000);
586        assert_eq!(rate("8.0 GT/s"), 6000);
587        assert_eq!(rate("16 GT/s PCIe"), 12000);
588        assert_eq!(rate("32 GT/s"), 24000);
589        assert_eq!(rate("64 GT/s"), 48000);
590        // An unrecognized rate errors so the caller warns, rather than
591        // substituting a default here where nothing would report it. "128 GT/s"
592        // stands in for a generation newer than the table.
593        for unknown in ["garbage", "", "128 GT/s"] {
594            assert!(
595                pcie_speed_mbits_per_lane(unknown).is_err(),
596                "{unknown:?} names no known PCIe generation"
597            );
598        }
599    }
600
601    #[test]
602    fn test_missing_link_attrs_fall_back_to_gen3_x16() {
603        // A path with no max_link_speed / max_link_width must not report a
604        // zero-bandwidth link: link bandwidths are combined with `min`, so a 0
605        // would erase every real measurement on the path.
606        let missing = Path::new("/nonexistent/pci/device");
607        assert_eq!(read_link_width(missing), DEFAULT_LINK_WIDTH);
608        assert_eq!(
609            read_speed_mbits_per_lane(missing),
610            DEFAULT_SPEED_MBITS_PER_LANE
611        );
612        assert_eq!(
613            link_bandwidth_mbytes_per_sec(missing),
614            12000,
615            "Gen3 (6000 Mbit/s per lane) x16 is 12000 MB/s"
616        );
617    }
618
619    fn hop(sysfs: &str, link_mbytes_per_sec: u32) -> PciHop {
620        PciHop {
621            sysfs: PathBuf::from(sysfs),
622            link_mbytes_per_sec,
623        }
624    }
625
626    #[test]
627    fn test_classify_pix_single_switch() {
628        // Both devices meet at a shared switch within two hops: PIX, with
629        // the bottleneck being the slowest link to that switch.
630        let a = vec![
631            hop("/d/a", 12000),
632            hop("/d/sw_a", 12000),
633            hop("/d/sw", 8000),
634        ];
635        let b = vec![hop("/d/b", 12000), hop("/d/sw_b", 6000), hop("/d/sw", 8000)];
636        let path = classify(&a, Some(0), &b, Some(0));
637        assert_eq!(path.path_type, PathType::Pix);
638        assert_eq!(path.bottleneck_mbytes_per_sec, 6000);
639    }
640
641    #[test]
642    fn test_classify_excludes_common_ancestor_upstream() {
643        // The common ancestor's own upstream link runs further up the tree and
644        // is not part of the path between `a` and `b`, so a slow link there
645        // must not lower the bottleneck.
646        let a = vec![hop("/d/a", 12000), hop("/d/sw", 2000)];
647        let b = vec![hop("/d/b", 12000), hop("/d/sw", 2000)];
648        let path = classify(&a, Some(0), &b, Some(0));
649        assert_eq!(path.path_type, PathType::Pix);
650        assert_eq!(
651            path.bottleneck_mbytes_per_sec, 12000,
652            "the common ancestor's 2000 MB/s upstream link is above the path and must be excluded"
653        );
654    }
655
656    #[test]
657    fn test_classify_pxb_multiple_switches() {
658        // The chains meet only at the root, several hops up one side: PXB.
659        let a = vec![
660            hop("/d/a", 12000),
661            hop("/d/sw_a1", 12000),
662            hop("/d/sw_a2", 12000),
663            hop("/d/sw_a3", 12000),
664            hop("/d/root", 16000),
665        ];
666        let b = vec![
667            hop("/d/b", 10000),
668            hop("/d/sw_b", 10000),
669            hop("/d/root", 16000),
670        ];
671        let path = classify(&a, Some(0), &b, Some(0));
672        assert_eq!(path.path_type, PathType::Pxb);
673        assert_eq!(path.bottleneck_mbytes_per_sec, 10000);
674    }
675
676    #[test]
677    fn test_classify_through_cpu() {
678        // No shared PCIe ancestor — the path goes through the CPU.
679        let a = vec![hop("/d/a", 4000), hop("/d/root_a", 16000)];
680        let b = vec![hop("/d/b", 8000), hop("/d/root_b", 16000)];
681        // Same NUMA node → PHB; bottleneck is the slowest link overall.
682        let phb = classify(&a, Some(0), &b, Some(0));
683        assert_eq!(phb.path_type, PathType::Phb);
684        assert_eq!(phb.bottleneck_mbytes_per_sec, 4000);
685        // Different NUMA nodes → SYS.
686        assert_eq!(classify(&a, Some(0), &b, Some(1)).path_type, PathType::Sys);
687    }
688
689    #[test]
690    fn test_classify_unknown_numa_is_reachable() {
691        // An unknown NUMA node can't be proven same-node, so the path is SYS
692        // (the worst reachable class) rather than DIS. Reporting DIS would
693        // drop the device from selection entirely.
694        let a = vec![hop("/d/a", 4000), hop("/d/root_a", 16000)];
695        let b = vec![hop("/d/b", 8000), hop("/d/root_b", 16000)];
696        assert_eq!(classify(&a, None, &b, Some(0)).path_type, PathType::Sys);
697        assert_eq!(classify(&a, Some(0), &b, None).path_type, PathType::Sys);
698        assert_eq!(classify(&a, None, &b, None).path_type, PathType::Sys);
699    }
700}