detect.rs 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. //! Per-node RoCE GID index auto-detection.
  2. //!
  3. //! `NCCL_IB_GID_INDEX` re-numbers across reboots on DGX Spark (the RoCE-v2
  4. //! IPv4 GID's index is not stable), so pinning it in config can go stale.
  5. //! When the cluster's `[nccl]` block omits `ib_gid_index`, the launcher
  6. //! resolves it per node at serve time by scanning the node's sysfs IB GID
  7. //! table for the first index whose type is "RoCE v2" over an IPv4-mapped GID
  8. //! (mirrors the Aiden/0rand serving stack's `compose.head.yaml` gid auto-detect:
  9. //! https://forums.developer.nvidia.com/t/372268/708). Explicit
  10. //! `ib_gid_index = <n>` in config opts out (pins the value).
  11. use crate::config::{Config, NcclConfig, Node};
  12. use crate::ssh::{run_ssh, shq};
  13. use anyhow::{Context, Result};
  14. /// Build the shell that scans a node's IB GID table for the RoCE-v2 IPv4 GID
  15. /// index. Mirrors the reference compose head/worker launch script: for each
  16. /// HCA in `NCCL_IB_HCA`, walk sysfs `gid_attrs/types` + `gids` up to index 15
  17. /// and pick the first index whose type is "RoCE v2" and whose GID is the
  18. /// IPv4-mapped form (`0000:0000:0000:0000:0000:ffff:*`).
  19. pub(crate) fn detect_gid_cmd(hcas: &str) -> String {
  20. // `hcas` is a comma-separated HCA list from config (trusted, but shell
  21. // quote it anyway so a crafted value cannot inject).
  22. let hcas_q = shq(hcas);
  23. format!(
  24. "for HCA in $(echo {hcas_q} | tr ',' ' '); do \
  25. for i in $(seq 0 15); do \
  26. t=$(cat /sys/class/infiniband/$HCA/ports/1/gid_attrs/types/$i 2>/dev/null || true); \
  27. g=$(cat /sys/class/infiniband/$HCA/ports/1/gids/$i 2>/dev/null || true); \
  28. case \"$t\" in *\"RoCE v2\"*) case \"$g\" in *0000:0000:0000:0000:0000:ffff:*) \
  29. echo \"$i\"; exit 0;; esac;; esac; \
  30. done; \
  31. done; \
  32. exit 1"
  33. )
  34. }
  35. /// Auto-detect the RoCE GID index on `node` by SSHing in and reading its
  36. /// sysfs GID table. On failure returns an error naming the HCA list and
  37. /// pointing at the explicit-`ib_gid_index` opt-out.
  38. pub(crate) async fn detect_gid_index_on_node(
  39. config: &Config,
  40. node: &Node,
  41. hcas: &str,
  42. ) -> Result<u32> {
  43. let out = run_ssh(config, node, detect_gid_cmd(hcas), false)
  44. .await
  45. .context("NCCL_IB_GID_INDEX auto-detect failed")?;
  46. let idx = out.trim().parse::<u32>().with_context(|| {
  47. format!(
  48. "Could not determine a RoCE-v2 IPv4 GID index on '{}' (HCA(s) {}). \
  49. Set `ib_gid_index = <n>` explicitly in the cluster's [nccl] section \
  50. to opt out of auto-detection.",
  51. node.name, hcas
  52. )
  53. })?;
  54. if idx > 15 {
  55. anyhow::bail!(
  56. "Auto-detected NCCL_IB_GID_INDEX={idx} on '{}' is out of range",
  57. node.name
  58. );
  59. }
  60. Ok(idx)
  61. }
  62. /// Resolve a node's NCCL config for launch: if `ib_gid_index` is pinned in
  63. /// config, return it unchanged; if `None` and `dry_run`, print the detect
  64. /// command and return a config keeps `ib_gid_index = None` (the docker env
  65. /// renders a placeholder); if `None` and a real run, SSH-detect per node and
  66. /// pin the resolved value.
  67. pub(crate) async fn resolve_nccl_for_node(
  68. config: &Config,
  69. node: &Node,
  70. nccl: Option<&NcclConfig>,
  71. dry_run: bool,
  72. ) -> Result<Option<NcclConfig>> {
  73. let Some(nccl) = nccl else {
  74. // Single-node recipe — no NCCL config at all.
  75. return Ok(None);
  76. };
  77. if nccl.ib_gid_index.is_some() {
  78. // Pinned (opt-out) — use as-is.
  79. return Ok(Some(nccl.clone()));
  80. }
  81. if dry_run {
  82. // No SSH in dry-run; show the detect command and leave the value
  83. // unresolved so the printed `-e NCCL_IB_GID_INDEX=...` is a
  84. // self-explanatory placeholder.
  85. println!(
  86. " ssh {}@{}: {}",
  87. config.ssh_user,
  88. node.ssh_ip,
  89. detect_gid_cmd(&nccl.ib_hca)
  90. );
  91. return Ok(Some(nccl.clone()));
  92. }
  93. let idx = detect_gid_index_on_node(config, node, &nccl.ib_hca).await?;
  94. let mut resolved = nccl.clone();
  95. resolved.ib_gid_index = Some(idx);
  96. Ok(Some(resolved))
  97. }
  98. #[cfg(test)]
  99. mod tests {
  100. use super::*;
  101. use crate::config::NcclConfig;
  102. #[test]
  103. fn detect_cmd_scans_roce_v2_ipv4() {
  104. let cmd = detect_gid_cmd("rocep1s0f1,roceP2p1s0f1");
  105. // HCA list is shell-quoted; both HCAs are iterated in order.
  106. assert!(cmd.contains("'rocep1s0f1,roceP2p1s0f1'"));
  107. assert!(cmd.contains("gid_attrs/types"));
  108. assert!(cmd.contains("0000:0000:0000:0000:0000:ffff:"));
  109. assert!(cmd.contains("RoCE v2"));
  110. assert!(cmd.contains("seq 0 15"));
  111. // Must fail loudly when nothing matches.
  112. assert!(cmd.ends_with("exit 1"));
  113. }
  114. #[tokio::test]
  115. async fn resolve_returns_pinned_config_unchanged() {
  116. // A pinned (opt-out) `ib_gid_index` must short-circuit with no SSH —
  117. // `detect_gid_index_on_node` is never reached, so this works even
  118. // with an unreachable node.
  119. let nccl = NcclConfig {
  120. ib_hca: "r".into(),
  121. socket_ifname: "e".into(),
  122. ib_gid_index: Some(3),
  123. ib_disable: 0,
  124. gloo_socket_ifname: None,
  125. ucx_net_devices: None,
  126. nccl_cross_nic: None,
  127. nccl_proto: None,
  128. nccl_p2p_level: None,
  129. };
  130. let node = crate::config::Node {
  131. name: "n1".into(),
  132. ssh_ip: "10.0.0.1".into(),
  133. ib_ip: Some("10.0.1.1".into()),
  134. rank: 0,
  135. host_key: None,
  136. entity: None,
  137. };
  138. let config = crate::config::Config {
  139. ssh_user: "u".into(),
  140. ssh_key_path: std::path::PathBuf::from("/p"),
  141. clusters: vec![],
  142. nccl: Default::default(),
  143. hf_cache_dir: None,
  144. init_port: 30000,
  145. before_serve: Default::default(),
  146. exec_aliases: Default::default(),
  147. home_assistant: None,
  148. mikrotik: None,
  149. global_mods_dir: None,
  150. drop_caches: false,
  151. mount_aliases: Default::default(),
  152. recipe_sources: Vec::new(),
  153. docker_image_sources: Vec::new(),
  154. cluster_sources: Vec::new(),
  155. registry: None,
  156. };
  157. for dry in [false, true] {
  158. let r = resolve_nccl_for_node(&config, &node, Some(&nccl), dry)
  159. .await
  160. .unwrap()
  161. .unwrap();
  162. assert_eq!(r.ib_gid_index, Some(3));
  163. }
  164. }
  165. }