fabric-check.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. #!/usr/bin/env python3
  2. """Before vLLM starts, every rank meets on the head, compares what must be the
  3. same on every node, and times an NCCL all-reduce over the fabric.
  4. A fabric can come up linked at full rate with healthy PCIe and still move
  5. 12 Gb/s (2026-09-26: fixed only by draining the boxes' power), and a node can
  6. run a stale copy of an override file. Neither fails anything; both show up
  7. later as a slow or subtly different model. The head prints what it found.
  8. Warnings only: it always exits 0, and a rank that finds no peers within
  9. FABRIC_CHECK_TIMEOUT_S skips the check.
  10. On a ring (FABRIC_LAYOUT=ring) the ranks compare facts through the TCP store
  11. and skip the all-reduce. This runs before mentat places the ranks in cable
  12. order, so an NCCL collective here would route to diagonal boxes that share
  13. no cable, and hang.
  14. """
  15. import datetime
  16. import hashlib
  17. import importlib.metadata
  18. import json
  19. import os
  20. import socket
  21. import subprocess
  22. import time
  23. PORT = int(os.environ.get("FABRIC_CHECK_PORT", "29511"))
  24. TIMEOUT_S = int(os.environ.get("FABRIC_CHECK_TIMEOUT_S", "120"))
  25. PROBE_MIB = 256
  26. # Bus bandwidth an all-reduce reaches on a healthy fabric, per fabric device
  27. # (one per ConnectX PCIe root); below 80% of it the check warns.
  28. GBPS_PER_DEVICE = float(os.environ.get("FABRIC_CHECK_GBPS_PER_DEVICE", "95"))
  29. # Knobs every rank must agree on. Per-node values (addresses, device names,
  30. # the GID index, which the kernel assigns per boot) are listed, not compared.
  31. SAME_ENV = ("TP", "MAX_NUM_BATCHED_TOKENS", "MAX_MODEL_LEN", "BLOCK_SIZE", "KV_CACHE_DTYPE", "KV_CACHE_MEMORY",
  32. "GPU_MEM_UTIL", "SPEC_METHOD", "SPEC_TOKENS", "MOE_BACKEND", "EXTRA_ARGS", "MTP", "HEAD_HOST")
  33. def sha(path: str) -> str:
  34. try:
  35. with open(path, "rb") as f:
  36. return hashlib.sha256(f.read()).hexdigest()[:12]
  37. except OSError:
  38. return "missing"
  39. def mounted_files() -> dict:
  40. """Files bind-mounted over the image (the compose overrides), by mount point."""
  41. out = {}
  42. with open("/proc/self/mountinfo") as f:
  43. for line in f:
  44. target = line.split()[4]
  45. if target.startswith(("/usr/local/lib/", "/opt/")) and os.path.isfile(target):
  46. out["mount " + target.rsplit("/dist-packages/", 1)[-1]] = sha(target)
  47. return out
  48. def facts() -> tuple[dict, dict]:
  49. """(values that must match on every rank, per-node values to list)."""
  50. import torch
  51. driver = subprocess.run(["nvidia-smi", "--query-gpu=driver_version", "--format=csv,noheader"],
  52. capture_output=True, text=True).stdout.strip()
  53. model = os.environ.get("MODEL_DIR", "/models/glm-5.3-flash-nvfp4")
  54. same = {
  55. "vllm": importlib.metadata.version("vllm"),
  56. "torch": torch.__version__,
  57. "nccl": ".".join(map(str, torch.cuda.nccl.version())),
  58. "driver": driver,
  59. "entrypoint": sha("/entrypoint.sh"),
  60. "discover": sha("/discover.sh"),
  61. "model config": sha(f"{model}/config.json"),
  62. "chat template": sha(os.environ.get("CHAT_TEMPLATE") or "/usr/local/share/glm53-chat-template.jinja"),
  63. "fabric devices": str(len([d for d in os.environ.get("NCCL_IB_HCA", "").split(",") if d])),
  64. }
  65. same.update({k: os.environ.get(k, "") for k in SAME_ENV})
  66. same.update({k: v for k, v in os.environ.items() if k.startswith("VLLM_") and k != "VLLM_HOST_IP"})
  67. same.update(mounted_files())
  68. node = {"host": socket.gethostname(), "gid": os.environ.get("NCCL_IB_GID_INDEX", "?"),
  69. "devices": os.environ.get("NCCL_IB_HCA", "?"), "ip": os.environ.get("VLLM_HOST_IP", "?")}
  70. return same, node
  71. def main() -> None:
  72. world = int(os.environ.get("TP", "4"))
  73. head = os.environ.get("ROLE") == "head"
  74. if world < 2:
  75. return
  76. import torch
  77. import torch.distributed as dist
  78. ring = os.environ.get("FABRIC_LAYOUT") == "ring"
  79. timeout = datetime.timedelta(seconds=TIMEOUT_S)
  80. try:
  81. store = dist.TCPStore(os.environ["HEAD_HOST"], PORT, world_size=world, is_master=head, timeout=timeout,
  82. wait_for_workers=False)
  83. rank = 0 if head else int(store.add("rank", 1))
  84. if rank >= world:
  85. print(f"fabric check: more than {world} ranks joined; skipped")
  86. return
  87. if ring:
  88. store.set(f"facts{rank}", json.dumps(facts()))
  89. everything = [json.loads(store.get(f"facts{r}")) for r in range(world)] if rank == 0 else None
  90. else:
  91. torch.cuda.set_device(0)
  92. dist.init_process_group("nccl", store=store, rank=rank, world_size=world, timeout=timeout,
  93. device_id=torch.device("cuda", 0))
  94. except Exception as e: # noqa: BLE001 -- a missing peer must not stop the boot
  95. print(f"fabric check: skipped, no rendezvous with all {world} ranks within {TIMEOUT_S}s ({e})")
  96. return
  97. if ring:
  98. if rank == 0:
  99. report(world, everything, None)
  100. else:
  101. print(f"fabric check: rank {rank} took part; the head prints the results")
  102. return
  103. same, node = facts()
  104. everything = [None] * world
  105. dist.all_gather_object(everything, (same, node))
  106. x = torch.ones(PROBE_MIB << 19, dtype=torch.bfloat16, device="cuda")
  107. for _ in range(2):
  108. dist.all_reduce(x)
  109. torch.cuda.synchronize()
  110. dist.barrier()
  111. t = time.perf_counter()
  112. for _ in range(5):
  113. dist.all_reduce(x)
  114. torch.cuda.synchronize()
  115. ms = (time.perf_counter() - t) / 5 * 1e3
  116. bus = (PROBE_MIB << 20) * 8 / (ms / 1e3) / 1e9 * 2 * (world - 1) / world
  117. dist.destroy_process_group()
  118. if rank != 0:
  119. print(f"fabric check: rank {rank} took part; the head prints the results")
  120. return
  121. report(world, everything, (ms, bus))
  122. def report(world: int, everything: list, probe: tuple[float, float] | None) -> None:
  123. """The head's table: each rank, the all-reduce timing (None on a ring), and any value that differs."""
  124. print(f"fabric check ({world} ranks):")
  125. print(" rank host ip gid devices")
  126. for r, (_, n) in enumerate(everything):
  127. print(f" {r:<5} {n['host']:<13} {n['ip']:<16} {n['gid']:<4} {n['devices']}")
  128. if probe is None:
  129. print(" ok all-reduce skipped on a ring: this runs before mentat places the ranks in cable order")
  130. else:
  131. ms, bus = probe
  132. devices = min(int(s["fabric devices"]) for s, _ in everything)
  133. expect = GBPS_PER_DEVICE * max(devices, 1)
  134. status = "ok " if bus >= 0.8 * expect else "WARN"
  135. print(f" {status} all-reduce {PROBE_MIB} MiB: {ms:.1f} ms, bus bandwidth {bus:.0f} Gb/s "
  136. f"(expected ~{expect:.0f} with {devices} device{'s' if devices != 1 else ''} per node)")
  137. keys = sorted(set().union(*(s.keys() for s, _ in everything)))
  138. bad = 0
  139. for k in keys:
  140. vals = [s.get(k, "(absent)") for s, _ in everything]
  141. if len(set(vals)) > 1:
  142. bad += 1
  143. per = ", ".join(f"{n['host']}={v}" for v, (_, n) in zip(vals, everything))
  144. print(f" WARN differs: {k}: {per}")
  145. if not bad:
  146. print(f" ok {len(keys)} values match on every rank")
  147. if __name__ == "__main__":
  148. os.environ["NCCL_DEBUG"] = "WARN" # the entrypoint sets INFO for vLLM; it would bury the table
  149. main()