1use 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
37pub enum IbvDeviceTarget {
38 MemoryLocation(MemoryLocation),
40 Nic(String),
42}
43
44impl IbvDeviceTarget {
45 pub fn cpu(numa: u32) -> Self {
47 Self::MemoryLocation(MemoryLocation::Cpu(Some(numa)))
48 }
49
50 pub fn gpu(ordinal: u32) -> Self {
52 Self::MemoryLocation(MemoryLocation::Gpu(Some(ordinal)))
53 }
54
55 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
94pub(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#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum PeerDeviceAffinityPolicy {
122 Any,
124 MatchName,
127 Groups(Vec<BTreeSet<String>>),
131}
132
133impl PeerDeviceAffinityPolicy {
134 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 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 fn choose_across_groups(
222 groups: &[BTreeSet<String>],
223 devices: &[String],
224 limit: usize,
225 ) -> Vec<String> {
226 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 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
320fn 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 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
345pub(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
358pub 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
371pub 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
404fn compute_optimal_ibv_devices<I: IbvDeviceImpl>(
412 location: MemoryLocation,
413) -> Result<Vec<IbvDeviceInfo>> {
414 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 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(¤t) => 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
453fn 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
464fn nic_path(nic: &IbvDeviceInfo, location: MemoryLocation, gpus: &[PCIAddress]) -> Result<PciPath> {
471 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
489fn 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
500pub 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
526pub 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 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 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 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 Some(0),
633 None,
635 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 vec![Some(0), None],
651 );
652 }
653
654 #[test]
655 fn groups_pair_within_a_group_only() {
656 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 Some(1),
671 Some(0),
673 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 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 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 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 assert_eq!(
730 chosen(&policy, &local, 4),
731 devices(["mlx5_0", "mlx5_2", "mlx5_4", "mlx5_6"]),
732 );
733 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 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 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 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 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 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 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 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 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 "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 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 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 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}