Skip to main content

monarch_rdma/backend/ibverbs/
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//! ibverbs-specific device selection: pairs a [`MemoryLocation`] with the
10//! RDMA NIC(s) that have the best PCIe path to it.
11
12use std::collections::BTreeSet;
13use std::num::NonZeroU32;
14use std::num::NonZeroUsize;
15use std::str::FromStr;
16use std::sync::LazyLock;
17
18use anyhow::Context;
19use anyhow::Result;
20use dashmap::DashMap;
21use dashmap::DashSet;
22
23use super::device::IbvDevice;
24use super::device::IbvDeviceImpl;
25use super::primitives::IbvDeviceInfo;
26use crate::device_selection::MemoryLocation;
27use crate::device_selection::PCIAddress;
28use crate::device_selection::PciPath;
29use crate::device_selection::cpu_path;
30use crate::device_selection::cuda_device_count;
31use crate::device_selection::cuda_pci_address;
32use crate::device_selection::pci_path;
33
34/// What an [`IbvConfig`](super::primitives::IbvConfig) targets: a memory
35/// location (whose best NIC is auto-selected) or an explicit NIC by name.
36#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
37pub enum IbvDeviceTarget {
38    /// Auto-select the best NIC for a CPU/GPU memory location.
39    MemoryLocation(MemoryLocation),
40    /// Use the NIC with this exact device name (e.g. `"mlx5_0"`).
41    Nic(String),
42}
43
44impl IbvDeviceTarget {
45    /// Target the best NIC for CPU memory on NUMA node `numa`.
46    pub fn cpu(numa: u32) -> Self {
47        Self::MemoryLocation(MemoryLocation::Cpu(Some(numa)))
48    }
49
50    /// Target the best NIC for GPU memory on CUDA ordinal `ordinal`.
51    pub fn gpu(ordinal: u32) -> Self {
52        Self::MemoryLocation(MemoryLocation::Gpu(Some(ordinal)))
53    }
54
55    /// Target the NIC with the given device name.
56    pub fn nic(name: impl Into<String>) -> Self {
57        Self::Nic(name.into())
58    }
59}
60
61impl FromStr for IbvDeviceTarget {
62    type Err = anyhow::Error;
63
64    fn from_str(spec: &str) -> Result<Self> {
65        let (kind, value) = spec.split_once(':').with_context(|| {
66            format!(
67                "ibverbs target {spec:?} must be `cpu:<numa>`, `gpu:<ordinal>`, or `nic:<name>`"
68            )
69        })?;
70
71        let parse_index = |what: &str| -> Result<u32> {
72            value
73                .parse()
74                .with_context(|| format!("{what} {value:?} must be an integer in 0..={}", u32::MAX))
75        };
76
77        match kind {
78            "cpu" => Ok(Self::cpu(parse_index("cpu target NUMA node")?)),
79            "gpu" => Ok(Self::gpu(parse_index("gpu target ordinal")?)),
80            "nic" => {
81                anyhow::ensure!(
82                    !value.is_empty(),
83                    "nic target must name a device, for example `nic:mlx5_0`"
84                );
85                Ok(Self::nic(value))
86            }
87            other => anyhow::bail!(
88                "unknown ibverbs target kind {other:?}; expected `cpu`, `gpu`, or `nic`"
89            ),
90        }
91    }
92}
93
94/// Return the configured ibverbs target, or `None` when it is unset.
95///
96/// Returns an error when the non-empty value is malformed.
97pub(crate) fn configured_ibverbs_target() -> Result<Option<IbvDeviceTarget>> {
98    let spec = hyperactor_config::global::get_cloned(crate::config::RDMA_IBVERBS_TARGET);
99    let spec = spec.trim();
100    if spec.is_empty() {
101        return Ok(None);
102    }
103    let target = spec.parse().map_err(|error| {
104        anyhow::anyhow!(
105            "invalid RDMA_IBVERBS_TARGET (`rdma_ibverbs_target` in Python) value {spec:?}: {error}"
106        )
107    })?;
108    Ok(Some(target))
109}
110
111/// Which of a peer's NICs a local NIC is allowed to pair with for a transfer.
112///
113/// Two NICs both being up does not mean the fabric carries traffic between
114/// them. For example, some fabrics may be organized into planes, where NICs
115/// can only talk to other NICs in the same plane. Instead of trying to add
116/// complex, dynamic network topology detection into monarch, it's much easier
117/// to simply let the user tell us the topology of their network. This is
118/// configurable using the config attr.
119/// [`RDMA_PEER_DEVICE_AFFINITY`](crate::config::RDMA_PEER_DEVICE_AFFINITY).
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum PeerDeviceAffinityPolicy {
122    /// Every local NIC reaches every peer NIC.
123    Any,
124    /// A local NIC reaches a peer NIC only if the two carry the same device
125    /// name.
126    MatchName,
127    /// A local NIC reaches a peer NIC only where some group names both. A NIC
128    /// no group names reaches nothing. There may be any number of groups, of
129    /// any size, and they are disjoint, so a NIC belongs to at most one.
130    Groups(Vec<BTreeSet<String>>),
131}
132
133impl PeerDeviceAffinityPolicy {
134    /// Pair each device in `local` with at most one device in `remote`.
135    ///
136    /// Entry `i` is the index in `remote` that `local[i]` should use, or `None`
137    /// when it has no partner: no NIC appears in two pairs, so the shorter list
138    /// bounds how many pairs there are, and the policy can rule out the rest.
139    ///
140    /// Same-named NICs pair first, no matter what the policy says.
141    /// Whatever is left over pairs subject to the policy, taking the
142    /// first NIC in `remote` it can still reach.
143    ///
144    /// The leftovers are matched greedily in order, so an earlier device can
145    /// take the only partner a later one could have used. Maximizing the number
146    /// of pairs is likely not worth a matching algorithm here.
147    pub fn pairs(&self, local: &[String], remote: &[String]) -> Vec<Option<usize>> {
148        let mut pairs: Vec<Option<usize>> = vec![None; local.len()];
149        let mut taken = vec![false; remote.len()];
150
151        for (i, local_device) in local.iter().enumerate() {
152            let same_name = remote
153                .iter()
154                .position(|remote_device| remote_device == local_device)
155                .filter(|j| !taken[*j]);
156            if let Some(j) = same_name {
157                pairs[i] = Some(j);
158                taken[j] = true;
159            }
160        }
161
162        for (i, local_device) in local.iter().enumerate() {
163            if pairs[i].is_some() {
164                continue;
165            }
166            let candidate = remote
167                .iter()
168                .enumerate()
169                .find(|(j, remote_device)| !taken[*j] && self.can_pair(local_device, remote_device))
170                .map(|(j, _)| j);
171            if let Some(j) = candidate {
172                pairs[i] = Some(j);
173                taken[j] = true;
174            }
175        }
176        pairs
177    }
178
179    /// The devices to serve one buffer from: at most `max` of `devices`, or all
180    /// of them when `max` is `None`.
181    ///
182    /// Which devices are dropped is decided by position, so the caller decides
183    /// what "first" means by how it orders `devices`.
184    ///
185    /// Selection logic depends on the policy:
186    ///
187    /// - [`Self::Any`]: any two NICs can talk to each other, so buffers should
188    ///   spread across tied devices rather than always funneling through
189    ///   the first. `choose` picks a random offset into `devices`, then
190    ///   takes up to `max` starting from that offset, wrapping around if
191    ///   necessary.
192    /// - [`Self::MatchName`]: takes the first `max` NICs in the order in which
193    ///   they appear in devices. Callers should pass `devices` in a known,
194    ///   consistent order so that different processes agree on the choice.
195    /// - [`Self::Groups`]: takes one device from every group `devices` touches
196    ///   before taking a second from any of them, so a peer served by any of
197    ///   those groups still finds a partner here. A device no group names comes
198    ///   last: it reaches no peer device, so it is worth registering only once
199    ///   nothing else fills `max`.
200    pub fn choose(&self, devices: &[String], max: Option<NonZeroUsize>) -> Vec<String> {
201        if devices.is_empty() {
202            return Vec::new();
203        }
204        let limit = max
205            .map_or(devices.len(), NonZeroUsize::get)
206            .min(devices.len());
207        match self {
208            Self::Any => devices
209                .iter()
210                .cycle()
211                .skip(rand::random_range(0..devices.len()))
212                .take(limit)
213                .cloned()
214                .collect(),
215            Self::MatchName => devices.iter().take(limit).cloned().collect(),
216            Self::Groups(groups) => Self::choose_across_groups(groups, devices, limit),
217        }
218    }
219
220    /// [`Self::Groups`]'s arm of [`Self::choose`].
221    fn choose_across_groups(
222        groups: &[BTreeSet<String>],
223        devices: &[String],
224        limit: usize,
225    ) -> Vec<String> {
226        // One bucket per group `devices` touches, created on first touch and
227        // filled in `devices` order.
228        let mut buckets: Vec<Vec<&String>> = Vec::new();
229        let mut bucket_of: Vec<Option<usize>> = vec![None; groups.len()];
230        let mut ungrouped: Vec<&String> = Vec::new();
231        for device in devices {
232            let Some(group) = groups.iter().position(|names| names.contains(device)) else {
233                ungrouped.push(device);
234                continue;
235            };
236            match bucket_of[group] {
237                Some(bucket) => buckets[bucket].push(device),
238                None => {
239                    bucket_of[group] = Some(buckets.len());
240                    buckets.push(vec![device]);
241                }
242            }
243        }
244        if buckets.len() > limit {
245            warn_uncovered_groups(limit, buckets.len(), &buckets[limit..]);
246        }
247
248        let rounds = buckets.iter().map(Vec::len).max().unwrap_or(0);
249        let mut ordered: Vec<&String> = Vec::with_capacity(devices.len());
250        for round in 0..rounds {
251            ordered.extend(
252                buckets
253                    .iter()
254                    .filter_map(|bucket| bucket.get(round))
255                    .copied(),
256            );
257        }
258        ordered.extend(ungrouped);
259        ordered.into_iter().take(limit).cloned().collect()
260    }
261
262    /// Whether a transfer may run between `local` and `remote`.
263    fn can_pair(&self, local: &str, remote: &str) -> bool {
264        match self {
265            Self::Any => true,
266            Self::MatchName => local == remote,
267            Self::Groups(groups) => groups
268                .iter()
269                .any(|group| group.contains(local) && group.contains(remote)),
270        }
271    }
272}
273
274impl FromStr for PeerDeviceAffinityPolicy {
275    type Err = anyhow::Error;
276
277    fn from_str(spec: &str) -> Result<Self> {
278        let spec = spec.trim();
279        match spec {
280            "" | "any" => Ok(Self::Any),
281            "match_name" => Ok(Self::MatchName),
282            _ => {
283                let groups = spec.strip_prefix("groups:").with_context(|| {
284                    format!(
285                        "peer device affinity {spec:?} must be `any`, `match_name`, or `groups:` \
286                         followed by `|`-separated groups of comma-separated device names"
287                    )
288                })?;
289                let groups = groups
290                    .split('|')
291                    .map(|group| {
292                        let names: BTreeSet<String> = group
293                            .split(',')
294                            .map(str::trim)
295                            .filter(|name| !name.is_empty())
296                            .map(str::to_owned)
297                            .collect();
298                        anyhow::ensure!(
299                            !names.is_empty(),
300                            "peer device affinity {spec:?} has a group naming no device",
301                        );
302                        Ok(names)
303                    })
304                    .collect::<Result<Vec<BTreeSet<String>>>>()?;
305
306                let mut claimed: BTreeSet<&String> = BTreeSet::new();
307                for name in groups.iter().flatten() {
308                    anyhow::ensure!(
309                        claimed.insert(name),
310                        "peer device affinity {spec:?} names {name:?} in more than one group; \
311                         groups must be disjoint",
312                    );
313                }
314                Ok(Self::Groups(groups))
315            }
316        }
317    }
318}
319
320/// Report, at most once per distinct set of left-out devices, that
321/// `rdma_max_nics_per_buffer` is too low for a buffer to reach every
322/// peer-affinity group that it could hypothetically reach.
323///
324/// `uncovered` holds the buckets [`PeerDeviceAffinityPolicy::choose`] had to
325/// drop, one per group it could not reach.
326fn warn_uncovered_groups(max: usize, groups: usize, uncovered: &[Vec<&String>]) {
327    static WARNED: LazyLock<DashSet<String>> = LazyLock::new(DashSet::new);
328    let mut left_out: Vec<&str> = uncovered
329        .iter()
330        .flatten()
331        .map(|device| device.as_str())
332        .collect();
333    left_out.sort();
334    // `insert` is true only for the first caller to claim this key, and it takes
335    // the shard lock, so exactly one of them warns.
336    if WARNED.insert(left_out.join(",")) {
337        tracing::warn!(
338            "rdma_max_nics_per_buffer is {max}, but this buffer's RDMA devices span {groups} \
339             peer-affinity groups; raise it to {groups} to also serve {left_out:?}, or else \
340             some peers may be unreachable.",
341        );
342    }
343}
344
345/// The configured [`PeerDeviceAffinityPolicy`].
346///
347/// Returns an error when the value is malformed.
348pub(crate) fn configured_peer_device_affinity() -> Result<PeerDeviceAffinityPolicy> {
349    let spec = hyperactor_config::global::get_cloned(crate::config::RDMA_PEER_DEVICE_AFFINITY);
350    spec.parse().map_err(|error| {
351        anyhow::anyhow!(
352            "invalid RDMA_PEER_DEVICE_AFFINITY (`rdma_peer_device_affinity` in Python) value \
353             {spec:?}: {error}"
354        )
355    })
356}
357
358/// The PCI address of an RDMA NIC, resolved from its sysfs device link
359/// (`/sys/class/infiniband/<name>/device`).
360pub fn get_pci_address(device: &IbvDeviceInfo) -> Result<PCIAddress> {
361    let link = format!("/sys/class/infiniband/{}/device", device.name());
362    let resolved =
363        std::fs::canonicalize(&link).with_context(|| format!("resolving sysfs link {link}"))?;
364    resolved
365        .file_name()
366        .and_then(|name| name.to_str())
367        .and_then(PCIAddress::parse)
368        .with_context(|| format!("no PCI address in resolved path {resolved:?}"))
369}
370
371/// The NIC(s) of backend `I` with the best path to `location`, ranked by
372/// [`PciPath::is_better_than`] (most local, then highest port-capped
373/// bandwidth) and returning all that tie for best.
374///
375/// A NIC that cannot be ranked -- no ACTIVE port, unresolvable PCI address -- is
376/// logged and skipped, so a host with no usable NIC yields an empty list rather
377/// than an error. Errors are reserved for a memory location that cannot be
378/// resolved at all, which no NIC could have compensated for.
379///
380/// A NIC's path bandwidth is the lesser of its PCIe-chain bottleneck and its
381/// RDMA port speed. Results are cached per `(backend, location)`, empty ones
382/// included, since the PCI/NUMA topology is fixed for the process lifetime. A
383/// host whose links are all down at the first call therefore keeps an empty
384/// answer for the rest of the process.
385///
386/// The NICs come back in lexicographic order by name, per
387/// [`compute_optimal_ibv_devices`]; the cache hands back the same order.
388/// Callers rely on it to agree with their peers on which of several tied NICs to
389/// use without exchanging anything.
390pub fn select_optimal_ibv_devices<I: IbvDeviceImpl>(
391    location: MemoryLocation,
392) -> Result<Vec<IbvDeviceInfo>> {
393    static CACHE: LazyLock<DashMap<(&'static str, MemoryLocation), Vec<IbvDeviceInfo>>> =
394        LazyLock::new(DashMap::new);
395    let key = (I::typename(), location);
396    if let Some(cached) = CACHE.get(&key) {
397        return Ok(cached.value().clone());
398    }
399    let result = compute_optimal_ibv_devices::<I>(location)?;
400    CACHE.insert(key, result.clone());
401    Ok(result)
402}
403
404/// Uncached core of [`select_optimal_ibv_devices`], returning the tied-best NICs
405/// in lexicographic order by device name.
406///
407/// That order is part of the contract rather than an artifact of how the devices
408/// were enumerated: two processes ranking the same [`MemoryLocation`] over the
409/// same NICs produce the same sequence, so each can pick the same element — the
410/// first, say — and know the other picked its counterpart.
411fn compute_optimal_ibv_devices<I: IbvDeviceImpl>(
412    location: MemoryLocation,
413) -> Result<Vec<IbvDeviceInfo>> {
414    // Resolve the GPU side once, before ranking NICs. An unresolvable GPU
415    // location is a fact about the request, not a reason to skip each NIC in
416    // turn.
417    let gpus = match location {
418        MemoryLocation::Cpu(_) => Vec::new(),
419        MemoryLocation::Gpu(ordinal) => gpu_pci_addresses(ordinal).with_context(|| {
420            format!(
421                "cannot rank RDMA NICs for {location:?}; if the CUDA driver is not initialized \
422                 in this process, initialize it first -- allocate a tensor on the device, call \
423                 torch.cuda.init(), or use your method of choice."
424            )
425        })?,
426    };
427
428    let mut best: Option<PciPath> = None;
429    let mut devices: Vec<IbvDeviceInfo> = Vec::new();
430    for nic in IbvDevice::<I>::list() {
431        // A NIC that cannot be ranked is not a failure of the request; drop it
432        // and say why, rather than failing the whole ranking or skipping in
433        // silence.
434        let path = match nic_path(&nic, location, &gpus) {
435            Ok(path) => path,
436            Err(error) => {
437                tracing::warn!("excluding RDMA device {}: {error:#}", nic.name());
438                continue;
439            }
440        };
441        match best {
442            Some(current) if current.is_better_than(&path) => continue,
443            Some(current) if path.is_better_than(&current) => devices.clear(),
444            _ => {}
445        }
446        best = Some(path);
447        devices.push(nic);
448    }
449    devices.sort_by(|a, b| a.name().cmp(b.name()));
450    Ok(devices)
451}
452
453/// The PCI addresses a [`MemoryLocation::Gpu`] names: just the one for a specific
454/// ordinal, or every visible device in ordinal order when unspecified.
455fn gpu_pci_addresses(ordinal: Option<u32>) -> Result<Vec<PCIAddress>> {
456    match ordinal {
457        Some(ordinal) => Ok(vec![cuda_pci_address(ordinal)?]),
458        None => (0..cuda_device_count()? as u32)
459            .map(cuda_pci_address)
460            .collect(),
461    }
462}
463
464/// The best [`PciPath`] from `location` to `nic`, capped by the NIC's RDMA port
465/// speed. Errors when `nic` cannot be ranked, naming the reason so the caller
466/// can report which device it dropped and why.
467///
468/// `gpus` holds the already-resolved GPU addresses for a
469/// [`MemoryLocation::Gpu`] location, and is empty for a CPU location.
470fn nic_path(nic: &IbvDeviceInfo, location: MemoryLocation, gpus: &[PCIAddress]) -> Result<PciPath> {
471    // A NIC with no ACTIVE port cannot carry traffic, so drop it from the
472    // candidate set. Leaving it in with a zero bandwidth is not equivalent:
473    // `is_better_than` compares `PathType` first, so a down NIC that happens to
474    // be PCIe-closer would outrank a working but more distant one.
475    let port_speed_mbytes_per_sec =
476        NonZeroU32::new(nic.port_speed_mbytes_per_sec()).context("no ACTIVE port")?;
477    let nic_addr = get_pci_address(nic)?;
478    let base = match location {
479        MemoryLocation::Cpu(numa) => cpu_path(&nic_addr, numa),
480        MemoryLocation::Gpu(_) => gpus
481            .iter()
482            .map(|gpu_addr| pci_path(gpu_addr, &nic_addr))
483            .reduce(|a, b| if b.is_better_than(&a) { b } else { a })
484            .context("no visible CUDA device to measure a path from")?,
485    };
486    Ok(cap_by_port_speed(base, port_speed_mbytes_per_sec))
487}
488
489/// Caps `path`'s bottleneck at the NIC's RDMA port speed, so a wide PCIe path
490/// to a slow NIC is not ranked as though the NIC could saturate it.
491fn cap_by_port_speed(path: PciPath, port_speed_mbytes_per_sec: NonZeroU32) -> PciPath {
492    PciPath {
493        bottleneck_mbytes_per_sec: path
494            .bottleneck_mbytes_per_sec
495            .min(port_speed_mbytes_per_sec.get()),
496        ..path
497    }
498}
499
500/// Resolves an [`IbvDeviceTarget`] to a single NIC of backend `I`: the
501/// named device for [`IbvDeviceTarget::Nic`], or the best NIC for a memory
502/// location. Both arms are scoped to backend `I`, so a name belonging to a
503/// different backend resolves to `Ok(None)`.
504///
505/// Where several NICs tie for a memory location, this takes the first, which by
506/// [`select_optimal_ibv_devices`]'s ordering is the lexicographically smallest
507/// name. Two processes pinning the same location over the same NICs therefore
508/// resolve to the same device.
509///
510/// `Ok(None)` means "no such device"; an error means the target could not be
511/// evaluated at all, which for a GPU location includes CUDA not being
512/// initialized in this process.
513pub fn resolve_target<I: IbvDeviceImpl>(target: &IbvDeviceTarget) -> Result<Option<IbvDeviceInfo>> {
514    match target {
515        IbvDeviceTarget::Nic(name) => Ok(IbvDevice::<I>::list()
516            .into_iter()
517            .find(|device| device.name() == name)),
518        IbvDeviceTarget::MemoryLocation(location) => {
519            Ok(select_optimal_ibv_devices::<I>(*location)?
520                .into_iter()
521                .next())
522        }
523    }
524}
525
526/// The NICs of backend `I` for each CUDA runtime ordinal, indexed by ordinal.
527///
528/// Each entry holds every NIC tied for the best path to that ordinal, in
529/// lexicographic order by name per [`select_optimal_ibv_devices`], so an entry
530/// is empty when no NIC could be ranked against its ordinal. Errors only when
531/// the ordinals themselves cannot be enumerated, which in practice means the
532/// CUDA driver is not initialized in this process.
533pub fn get_cuda_device_to_ibv_devices<I: IbvDeviceImpl>() -> Result<Vec<Vec<IbvDeviceInfo>>> {
534    (0..cuda_device_count()?)
535        .map(|ordinal| select_optimal_ibv_devices::<I>(MemoryLocation::Gpu(Some(ordinal as u32))))
536        .collect()
537}
538
539#[cfg(test)]
540mod tests {
541    use super::super::mlx_device::MlxDevice;
542    use super::*;
543
544    #[test]
545    fn parses_each_target_kind() {
546        assert_eq!(
547            "cpu:0"
548                .parse::<IbvDeviceTarget>()
549                .expect("cpu target should parse"),
550            IbvDeviceTarget::cpu(0),
551        );
552        assert_eq!(
553            "gpu:1"
554                .parse::<IbvDeviceTarget>()
555                .expect("gpu target should parse"),
556            IbvDeviceTarget::gpu(1),
557        );
558        assert_eq!(
559            "nic:mlx5_0"
560                .parse::<IbvDeviceTarget>()
561                .expect("NIC target should parse"),
562            IbvDeviceTarget::nic("mlx5_0"),
563        );
564    }
565
566    #[test]
567    fn rejects_malformed_targets() {
568        for invalid in ["", "cpu", "cpu:", "cpu:x", "gpu:-1", "nic:", "other:0"] {
569            assert!(
570                invalid.parse::<IbvDeviceTarget>().is_err(),
571                "expected {invalid:?} to be rejected",
572            );
573        }
574    }
575
576    #[test]
577    fn empty_configured_target_is_unset() {
578        let lock = hyperactor_config::global::lock();
579        let _guard = lock.override_key(crate::config::RDMA_IBVERBS_TARGET, String::new());
580        assert_eq!(
581            configured_ibverbs_target().expect("empty target should be valid"),
582            None,
583        );
584    }
585
586    #[test]
587    fn parses_configured_target() {
588        let lock = hyperactor_config::global::lock();
589        let _guard = lock.override_key(
590            crate::config::RDMA_IBVERBS_TARGET,
591            "  nic:mlx5_1  ".to_string(),
592        );
593
594        assert_eq!(
595            configured_ibverbs_target().expect("configured target should be valid"),
596            Some(IbvDeviceTarget::nic("mlx5_1")),
597        );
598    }
599
600    /// Device names in [`select_optimal_ibv_devices`] order, which is sorted by
601    /// name -- what [`PeerDeviceAffinityPolicy::choose`] is handed.
602    fn devices<const N: usize>(names: [&str; N]) -> Vec<String> {
603        assert!(names.is_sorted(), "a ranking comes back sorted by name");
604        names.map(str::to_owned).to_vec()
605    }
606
607    /// Every device is paired at most once on either side.
608    fn assert_one_pair_per_device(pairs: &[Option<usize>], remote_len: usize) {
609        let mut used = vec![false; remote_len];
610        for peer in pairs.iter().flatten() {
611            assert!(
612                !std::mem::replace(&mut used[*peer], true),
613                "peer device {peer} was paired twice in {pairs:?}",
614            );
615        }
616    }
617
618    #[test]
619    fn same_names_pair_before_anything_else() {
620        // `Any` permits every pairing, so only the name-first pass decides this
621        // one. Both sides lead with a NIC the other does not have, and the
622        // shared `mlx5_5` sits at a different index on each side: pairing by
623        // position would hand `mlx5_2` the peer's `mlx5_5` and leave the local
624        // `mlx5_5` with nothing.
625        let local = devices(["mlx5_0", "mlx5_2", "mlx5_5"]);
626        let remote = devices(["mlx5_3", "mlx5_5"]);
627        let pairs = PeerDeviceAffinityPolicy::Any.pairs(&local, &remote);
628        assert_eq!(
629            pairs,
630            vec![
631                // mlx5_0 takes what the name match left: the peer's mlx5_3.
632                Some(0),
633                // Nothing is left for mlx5_2.
634                None,
635                // The name match, claimed before either leftover was placed.
636                Some(1),
637            ],
638        );
639        assert_one_pair_per_device(&pairs, remote.len());
640    }
641
642    #[test]
643    fn match_name_pairs_only_the_same_name() {
644        let local = devices(["mlx5_0", "mlx5_9"]);
645        let remote = devices(["mlx5_0", "mlx5_1"]);
646        assert_eq!(
647            PeerDeviceAffinityPolicy::MatchName.pairs(&local, &remote),
648            // `mlx5_9` has no counterpart on the peer, and `mlx5_1` is left
649            // unpaired rather than handed to it — which is what `Any` would do.
650            vec![Some(0), None],
651        );
652    }
653
654    #[test]
655    fn groups_pair_within_a_group_only() {
656        // The groups cross the two lists: `mlx5_0` may only reach the peer's
657        // second NIC and `mlx5_2` only its first, so the pairing has to invert
658        // the list order. No name is shared, so nothing here comes from the
659        // name-first pass either.
660        let policy: PeerDeviceAffinityPolicy = "groups:mlx5_0,mlx5_3|mlx5_1,mlx5_2"
661            .parse()
662            .expect("group spec should parse");
663        let local = devices(["mlx5_0", "mlx5_2", "mlx5_7"]);
664        let remote = devices(["mlx5_1", "mlx5_3"]);
665        let pairs = policy.pairs(&local, &remote);
666        assert_eq!(
667            pairs,
668            vec![
669                // mlx5_0 shares a group with mlx5_3.
670                Some(1),
671                // mlx5_2 shares a group with mlx5_1.
672                Some(0),
673                // mlx5_7 is in no group, so it reaches nothing.
674                None,
675            ],
676        );
677        assert_one_pair_per_device(&pairs, remote.len());
678    }
679
680    #[test]
681    fn a_choice_between_peers_takes_the_first_reachable() {
682        // `mlx5_7` shares no name with the peer and can reach two of its NICs,
683        // so the pick is the first of those in order — skipping `mlx5_0`, which
684        // its group does not name.
685        let policy: PeerDeviceAffinityPolicy = "groups:mlx5_1,mlx5_3,mlx5_7"
686            .parse()
687            .expect("group spec should parse");
688        let local = devices(["mlx5_6", "mlx5_7"]);
689        let remote = devices(["mlx5_0", "mlx5_1", "mlx5_3"]);
690        let pairs = policy.pairs(&local, &remote);
691        assert_eq!(
692            pairs,
693            // mlx5_6 is in no group, so it reaches nothing.
694            vec![None, Some(1)],
695        );
696        assert_one_pair_per_device(&pairs, remote.len());
697    }
698
699    #[test]
700    fn pairs_over_empty_lists_are_empty() {
701        let local = devices(["mlx5_0", "mlx5_1"]);
702        assert!(PeerDeviceAffinityPolicy::Any.pairs(&[], &local).is_empty());
703        assert_eq!(
704            PeerDeviceAffinityPolicy::Any.pairs(&local, &[]),
705            vec![None; local.len()],
706            "a peer advertising no device leaves every local NIC unpaired",
707        );
708    }
709
710    /// [`PeerDeviceAffinityPolicy::choose`] with a budget of `max`.
711    fn chosen(policy: &PeerDeviceAffinityPolicy, devices: &[String], max: usize) -> Vec<String> {
712        policy.choose(
713            devices,
714            Some(NonZeroUsize::new(max).expect("a device budget is at least one")),
715        )
716    }
717
718    #[test]
719    fn each_group_is_reached_before_any_is_doubled_up() {
720        let policy: PeerDeviceAffinityPolicy =
721            "groups:mlx5_0,mlx5_1|mlx5_2,mlx5_3|mlx5_4,mlx5_5|mlx5_6,mlx5_7"
722                .parse()
723                .expect("group spec should parse");
724        let local = devices([
725            "mlx5_0", "mlx5_1", "mlx5_2", "mlx5_3", "mlx5_4", "mlx5_5", "mlx5_6", "mlx5_7",
726        ]);
727        // One device out of each group. A prefix of the ranking would have taken
728        // mlx5_0 through mlx5_3 and left the last two groups unreachable.
729        assert_eq!(
730            chosen(&policy, &local, 4),
731            devices(["mlx5_0", "mlx5_2", "mlx5_4", "mlx5_6"]),
732        );
733        // Once every group is covered the budget doubles up within them.
734        assert_eq!(
735            chosen(&policy, &local, 6),
736            ["mlx5_0", "mlx5_2", "mlx5_4", "mlx5_6", "mlx5_1", "mlx5_3"].map(str::to_owned),
737        );
738        // The best-ranked device is still taken first.
739        assert_eq!(chosen(&policy, &local, 1), devices(["mlx5_0"]));
740    }
741
742    #[test]
743    fn a_group_the_devices_do_not_reach_costs_no_budget() {
744        let policy: PeerDeviceAffinityPolicy = "groups:mlx5_0,mlx5_1|mlx5_2,mlx5_3|mlx5_4,mlx5_5"
745            .parse()
746            .expect("group spec should parse");
747        // A buffer only two groups can serve — a GPU buffer, say — spends its
748        // whole budget on those two rather than holding room for the third.
749        let local = devices(["mlx5_1", "mlx5_2", "mlx5_3"]);
750        assert_eq!(
751            chosen(&policy, &local, 3),
752            devices(["mlx5_1", "mlx5_2", "mlx5_3"]),
753        );
754    }
755
756    #[test]
757    fn a_device_no_group_names_is_taken_last() {
758        let policy: PeerDeviceAffinityPolicy = "groups:mlx5_2,mlx5_3"
759            .parse()
760            .expect("group spec should parse");
761        // `mlx5_0` and `mlx5_1` reach no peer device under this policy, so the
762        // budget goes to the group first even though they rank higher.
763        let local = devices(["mlx5_0", "mlx5_1", "mlx5_2", "mlx5_3"]);
764        assert_eq!(chosen(&policy, &local, 1), devices(["mlx5_2"]));
765        assert_eq!(
766            chosen(&policy, &local, 3),
767            ["mlx5_2", "mlx5_3", "mlx5_0"].map(str::to_owned),
768        );
769    }
770
771    #[test]
772    fn match_name_takes_devices_in_order() {
773        // Both sides have to land on the same name.
774        let local = devices(["mlx5_0", "mlx5_1", "mlx5_2"]);
775        assert_eq!(
776            chosen(&PeerDeviceAffinityPolicy::MatchName, &local, 2),
777            devices(["mlx5_0", "mlx5_1"]),
778        );
779    }
780
781    #[test]
782    fn any_starts_at_a_random_device_and_wraps() {
783        let policy = PeerDeviceAffinityPolicy::Any;
784        let ranked = devices(["mlx5_0", "mlx5_1", "mlx5_2", "mlx5_3"]);
785        // Every pair of devices adjacent in the ranking, wrapping around.
786        let windows: BTreeSet<Vec<String>> = [
787            ["mlx5_0", "mlx5_1"].map(str::to_owned).to_vec(),
788            ["mlx5_1", "mlx5_2"].map(str::to_owned).to_vec(),
789            ["mlx5_2", "mlx5_3"].map(str::to_owned).to_vec(),
790            ["mlx5_3", "mlx5_0"].map(str::to_owned).to_vec(),
791        ]
792        .into();
793        let mut seen: BTreeSet<Vec<String>> = BTreeSet::new();
794        for _ in 0..64 {
795            let picked = chosen(&policy, &ranked, 2);
796            assert!(
797                windows.contains(&picked),
798                "{picked:?} does not start somewhere in {ranked:?} and run on from there",
799            );
800            seen.insert(picked);
801        }
802        // 64 draws that all miss one of four starts would be a one-in-billions
803        // coincidence.
804        assert_eq!(seen, windows);
805    }
806
807    #[test]
808    fn no_limit_takes_every_device() {
809        let local = devices(["mlx5_0", "mlx5_1", "mlx5_2", "mlx5_3"]);
810        for policy in [
811            PeerDeviceAffinityPolicy::Any,
812            PeerDeviceAffinityPolicy::MatchName,
813            "groups:mlx5_0,mlx5_1|mlx5_2,mlx5_3"
814                .parse()
815                .expect("group spec should parse"),
816        ] {
817            let mut unbounded = policy.choose(&local, None);
818            unbounded.sort();
819            assert_eq!(unbounded, local, "{policy:?} should leave no device behind");
820            // A budget larger than the list is the same as no budget at all.
821            let mut over_budget = chosen(&policy, &local, local.len() + 5);
822            over_budget.sort();
823            assert_eq!(
824                over_budget, local,
825                "{policy:?} should not repeat a device to fill its budget",
826            );
827        }
828    }
829
830    #[test]
831    fn one_device_per_group_pairs_with_a_peer_in_any_of_them() {
832        // What spreading over groups buys: a buffer that only one group can
833        // serve -- a GPU buffer bound to a single NIC -- still finds a partner,
834        // whichever group that NIC falls in.
835        let policy: PeerDeviceAffinityPolicy =
836            "groups:mlx5_0,mlx5_1|mlx5_2,mlx5_3|mlx5_4,mlx5_5|mlx5_6,mlx5_7"
837                .parse()
838                .expect("group spec should parse");
839        let ranked = devices([
840            "mlx5_0", "mlx5_1", "mlx5_2", "mlx5_3", "mlx5_4", "mlx5_5", "mlx5_6", "mlx5_7",
841        ]);
842        let local = chosen(&policy, &ranked, 4);
843        for peer in &ranked {
844            let remote = chosen(&policy, std::slice::from_ref(peer), 4);
845            let pairs = policy.pairs(&local, &remote);
846            assert_eq!(
847                pairs.iter().flatten().count(),
848                1,
849                "{peer} should pair with one of {local:?}, got {pairs:?}",
850            );
851        }
852    }
853
854    #[test]
855    fn parses_each_affinity_policy() {
856        for (spec, expected) in [
857            ("", PeerDeviceAffinityPolicy::Any),
858            ("any", PeerDeviceAffinityPolicy::Any),
859            ("  match_name  ", PeerDeviceAffinityPolicy::MatchName),
860            (
861                // Three groups, one of them a single device.
862                "groups:mlx5_0, mlx5_1|mlx5_2|mlx5_3,mlx5_4",
863                PeerDeviceAffinityPolicy::Groups(vec![
864                    ["mlx5_0", "mlx5_1"].map(str::to_owned).into(),
865                    ["mlx5_2"].map(str::to_owned).into(),
866                    ["mlx5_3", "mlx5_4"].map(str::to_owned).into(),
867                ]),
868            ),
869        ] {
870            assert_eq!(
871                spec.parse::<PeerDeviceAffinityPolicy>()
872                    .unwrap_or_else(|error| panic!("{spec:?} should parse: {error}")),
873                expected,
874            );
875        }
876    }
877
878    #[test]
879    fn rejects_malformed_affinity_policies() {
880        // A bare name, an unknown kind, group specs with an empty group, and
881        // overlapping groups.
882        for invalid in [
883            "mlx5_0",
884            "exact",
885            "groups",
886            "groups:",
887            "groups:mlx5_0|",
888            "groups:|mlx5_0",
889            "groups:mlx5_0,mlx5_1|mlx5_1,mlx5_2",
890        ] {
891            assert!(
892                invalid.parse::<PeerDeviceAffinityPolicy>().is_err(),
893                "expected {invalid:?} to be rejected",
894            );
895        }
896    }
897
898    #[test]
899    fn optimal_devices_come_back_in_name_order() {
900        // Peers pair their NICs by position in this list, so the order is part of
901        // the contract rather than a detail of how devices were enumerated. A
902        // host with no NIC trivially satisfies it, which is why this is not
903        // gated on hardware.
904        for location in [MemoryLocation::Cpu(None), MemoryLocation::Cpu(Some(0))] {
905            let names: Vec<String> = select_optimal_ibv_devices::<MlxDevice>(location)
906                .expect("ranking CPU memory needs no CUDA")
907                .iter()
908                .map(|nic| nic.name().clone())
909                .collect();
910            assert!(
911                names.is_sorted(),
912                "{location:?} ranked NICs out of order: {names:?}",
913            );
914        }
915    }
916
917    #[test]
918    fn nic_with_no_active_port_is_not_a_candidate() {
919        // `for_test_named` builds a device with no ports, so its port speed is 0.
920        // Such a NIC cannot carry traffic and must be rejected outright: capping
921        // it to zero bandwidth instead would leave it eligible, and
922        // `is_better_than` compares `PathType` before bandwidth.
923        let down = IbvDeviceInfo::for_test_named("mlx5_down");
924        assert_eq!(down.port_speed_mbytes_per_sec(), 0);
925        for location in [
926            MemoryLocation::Cpu(None),
927            MemoryLocation::Cpu(Some(0)),
928            MemoryLocation::Gpu(None),
929            MemoryLocation::Gpu(Some(0)),
930        ] {
931            let error = format!(
932                "{:#}",
933                nic_path(&down, location, &[])
934                    .expect_err("a NIC with no ACTIVE port must not be a selection candidate")
935            );
936            assert!(
937                error.contains("no ACTIVE port"),
938                "the error should name the reason the NIC was excluded, got: {error}"
939            );
940        }
941    }
942
943    #[test]
944    fn malformed_configured_target_names_the_setting() {
945        let lock = hyperactor_config::global::lock();
946        let _guard = lock.override_key(crate::config::RDMA_IBVERBS_TARGET, "mlx5_1".to_string());
947
948        let error = configured_ibverbs_target().expect_err("malformed target should fail");
949        assert!(error.to_string().contains("RDMA_IBVERBS_TARGET"));
950    }
951}