test_ring.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. #!/usr/bin/env python3
  2. """Bit-exact and speed test for arx and arxbig, in mesh or ring mode, without vLLM.
  3. Every rank builds every rank's inputs from fixed seeds and computes the
  4. result the way the kernels do (bf16 values summed in fp32 in rank order,
  5. rounded once). Each collective's output must match that reference bit for
  6. bit, and each rank's output must match what the other ranks expect it to
  7. hold. Covered: the arx all-reduce, arxbig's all_gather and reduce_scatter,
  8. and the fused MoE finalize + rs_finish path (with routing weights that are
  9. powers of two, so the reference can round the same way the kernel's FMAs
  10. do). Calls also go out in back-to-back bursts of mixed collectives, checked
  11. only afterwards, so buffer reuse and relays run at full speed. It then
  12. prints all-reduce latency and all-gather / reduce-scatter bandwidth.
  13. Mesh mode needs every box on the switch; ring mode needs the ring cabling,
  14. or the switch with both ARX_RING_*_HCAS set to the NCCL_IB_HCA devices (that
  15. checks the relay logic, not the speed). The two modes can't share one
  16. cabling, so each is its own run. Since both must match the same reference,
  17. passing both means they agree.
  18. Run one container per box from the repo root, rank 0 first, all within a
  19. minute. The serving stack must be down. Rank r must be ring member r in
  20. cable order (rank r's next port cabled to rank r+1's prev port).
  21. sudo docker run --rm --gpus all --network host --ipc host --ulimit memlock=-1 \\
  22. --device /dev/infiniband:/dev/infiniband -v "$PWD/experimental/arx:/arx:ro" \\
  23. -v /tmp/arx-ext:/root/.cache/torch_extensions \\
  24. -e RANK=<0..3> -e WORLD_SIZE=4 -e MASTER_ADDR=<rank 0's LAN address> -e MASTER_PORT=29610 \\
  25. -e VLLM_HOST_IP=<this box's LAN address> $FABRIC \\
  26. --entrypoint python3 <serving image> /arx/test_ring.py --mode <mesh|ring>
  27. with FABRIC, for mesh, the entrypoint's values (its log prints them):
  28. -e NCCL_IB_HCA=<root 0 device>,<root 1 device> -e NCCL_IB_GID_INDEX=<index>
  29. and for ring, the two functions of the port facing each neighbour, e.g.
  30. -e ARX_RING_PREV_HCAS=rocep1s0f0,roceP2p1s0f0 -e ARX_RING_NEXT_HCAS=rocep1s0f1,roceP2p1s0f1
  31. The /tmp/arx-ext mount keeps the compiled extensions between runs.
  32. WORLD_SIZE=2 works the same way. At WORLD_SIZE=3 ring mode is arx only:
  33. arxbig refuses a ring of 3, so this script stops at its setup. Every rank
  34. prints its failures and rank 0 ends with "RESULT <mode>: PASS" or "FAIL";
  35. the exit code is nonzero on a failure. A rank that makes no progress for
  36. 120 s says where it was and exits.
  37. """
  38. import argparse
  39. import hashlib
  40. import os
  41. import random
  42. import subprocess
  43. import sys
  44. import threading
  45. import time
  46. import zlib
  47. import torch
  48. import torch.distributed as dist
  49. BF16 = torch.bfloat16
  50. def lan_iface(ip: str) -> str:
  51. out = subprocess.run(["ip", "-o", "-4", "addr", "show"], capture_output=True, text=True).stdout
  52. for line in out.splitlines():
  53. f = line.split()
  54. if len(f) > 3 and f[3].split("/")[0] == ip:
  55. return f[1]
  56. sys.exit(f"no interface holds VLLM_HOST_IP={ip}")
  57. def gen(shape, dtype, *key) -> torch.Tensor:
  58. g = torch.Generator(device="cuda")
  59. g.manual_seed(zlib.crc32(repr(key).encode()))
  60. if dtype == torch.uint8:
  61. return torch.randint(0, 256, shape, generator=g, device="cuda", dtype=torch.uint8)
  62. return (torch.randn(shape, generator=g, device="cuda") * 4).to(dtype)
  63. def rank_order_sum(xs) -> torch.Tensor:
  64. acc = torch.zeros(xs[0].shape, dtype=torch.float32, device="cuda")
  65. for x in xs:
  66. acc += x.float()
  67. return acc.to(BF16)
  68. def raw(t: torch.Tensor) -> torch.Tensor:
  69. return t.contiguous().reshape(-1).view(torch.uint8)
  70. def digest(t: torch.Tensor) -> str:
  71. return hashlib.sha256(raw(t).cpu().numpy().tobytes()).hexdigest()[:16]
  72. class Watchdog:
  73. def __init__(self, rank: int, secs: float):
  74. self.rank, self.secs, self.what, self.t = rank, secs, "setup", time.time()
  75. threading.Thread(target=self._run, daemon=True).start()
  76. def at(self, what: str) -> None:
  77. self.what, self.t = what, time.time()
  78. def _run(self) -> None:
  79. while True:
  80. time.sleep(5)
  81. if time.time() - self.t > self.secs:
  82. print(f"rank {self.rank}: no progress for {self.secs:.0f} s in {self.what}", flush=True)
  83. os._exit(3)
  84. class Test:
  85. def __init__(self, mode: str):
  86. self.mode = mode
  87. self.rank, self.world = dist.get_rank(), dist.get_world_size()
  88. self.checks = self.failures = 0
  89. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
  90. import arx
  91. dev = torch.device("cuda", 0)
  92. self.ar = arx.ArxCommunicator(dist.group.WORLD, dev)
  93. self.big = arx.ArxBig(dist.group.WORLD, dev)
  94. if self.ar.disabled or self.big.disabled or not self.big.rs:
  95. sys.exit("arx or arxbig did not come up; see the warnings above")
  96. self.dog = Watchdog(self.rank, 120) # after the extensions compile
  97. # Each op issues one collective and returns (output, reference function,
  98. # kind): "same" outputs are the whole reference on every rank, "chunk"
  99. # outputs are this rank's chunk of it.
  100. def allreduce(self, n: int, key):
  101. ins = [gen((n,), BF16, "ar", key, s) for s in range(self.world)]
  102. out = torch.empty_like(ins[self.rank])
  103. self.ar.ext.allreduce(ins[self.rank], out, [], [])
  104. return out, lambda: rank_order_sum(ins), "same"
  105. def all_gather(self, nbytes: int, dtype, key):
  106. n = nbytes // torch.empty((), dtype=dtype).element_size()
  107. ins = [gen((n,), dtype, "ag", key, s) for s in range(self.world)]
  108. out = self.big.all_gather(ins[self.rank]).clone() # later gathers overwrite the returned slot
  109. return out, lambda: torch.cat(ins), "same"
  110. def reduce_scatter(self, chunk_bytes: int, key):
  111. n = chunk_bytes // 2 * self.world
  112. ins = [gen((n,), BF16, "rs", key, s) for s in range(self.world)]
  113. out = self.big.ext.reduce_scatter(ins[self.rank])
  114. return out, lambda: rank_order_sum(ins), "chunk"
  115. def finalize_rs(self, T: int, Tpad: int, H: int, key, topk: int = 2):
  116. def inputs(s):
  117. R = T * topk
  118. y = gen((R, H), BF16, "fy", key, s)
  119. shared = gen((T, H), BF16, "fs", key, s)
  120. g = torch.Generator(device="cuda")
  121. g.manual_seed(zlib.crc32(repr(("fp", key, s)).encode()))
  122. pos = torch.randint(0, R, (T * topk,), generator=g, device="cuda", dtype=torch.int32)
  123. w = 2.0 ** torch.randint(-2, 2, (T, topk), generator=g, device="cuda").float()
  124. return y, pos, w, shared
  125. mine = inputs(self.rank)
  126. seq = self.big.ext.moe_finalize_rs(*mine, T, Tpad, None)
  127. out = self.big.ext.rs_finish(seq, Tpad // self.world, H)
  128. def ref():
  129. rows = []
  130. for s in range(self.world):
  131. y, pos, w, shared = inputs(s)
  132. acc = shared.float()
  133. p = pos.view(T, topk).long()
  134. for q in range(topk):
  135. acc = acc + w[:, q:q + 1] * y[p[:, q]].float()
  136. r = torch.zeros((Tpad, H), dtype=BF16, device="cuda")
  137. r[:T] = acc.to(BF16)
  138. rows.append(r)
  139. return rank_order_sum(rows)
  140. return out, ref, "chunk"
  141. def check(self, name: str, out, ref_fn, kind: str) -> None:
  142. self.dog.at(f"check {name}")
  143. torch.cuda.synchronize()
  144. ref = ref_fn()
  145. if kind == "chunk":
  146. chunks = ref.chunk(self.world)
  147. want = [digest(c) for c in chunks]
  148. ok = torch.equal(raw(out), raw(chunks[self.rank]))
  149. else:
  150. want = [digest(ref)] * self.world
  151. ok = torch.equal(raw(out), raw(ref))
  152. got: list = [None] * self.world
  153. dist.all_gather_object(got, digest(out))
  154. self.checks += 1
  155. bad = [j for j in range(self.world) if got[j] != want[j]]
  156. if not ok or bad:
  157. self.failures += 1
  158. first = ""
  159. if not ok:
  160. exp = chunks[self.rank] if kind == "chunk" else ref
  161. diff = (raw(out) != raw(exp)).nonzero()
  162. first = f", first differing byte {diff[0].item()} of {raw(exp).numel()}"
  163. print(f"[{self.mode}] rank {self.rank} FAIL {name}: matches reference {ok}{first}; "
  164. f"ranks whose output differs from what this rank expects: {bad}", flush=True)
  165. def issue(self, op: str, size: int, key):
  166. if op == "ar":
  167. return self.allreduce(size // 2, key)
  168. if op == "ag":
  169. return self.all_gather(size, BF16, key)
  170. if op == "ag8":
  171. return self.all_gather(size, torch.uint8, key)
  172. if op == "rs":
  173. return self.reduce_scatter(size, key)
  174. Tpad = max(size // (2 * 1024), 8 * self.world)
  175. Tpad -= Tpad % (8 * self.world)
  176. return self.finalize_rs(Tpad - Tpad // 7, Tpad, 1024, key)
  177. def burst(self, name: str, ops) -> None:
  178. """Issues every op back to back, then checks them all."""
  179. self.dog.at(f"burst {name}")
  180. issued = [(f"{name}[{i}] {op} {size}", *self.issue(op, size, (name, i))) for i, (op, size) in enumerate(ops)]
  181. for label, out, ref_fn, kind in issued:
  182. self.check(label, out, ref_fn, kind)
  183. def bench(self, label: str, fn, iters: int) -> float:
  184. """Seconds per call, the slowest rank's."""
  185. self.dog.at(f"bench {label}")
  186. for _ in range(3):
  187. fn()
  188. torch.cuda.synchronize()
  189. dist.barrier()
  190. a, b = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True)
  191. a.record()
  192. for _ in range(iters):
  193. fn()
  194. b.record()
  195. torch.cuda.synchronize()
  196. t: list = [None] * self.world
  197. dist.all_gather_object(t, a.elapsed_time(b) / 1e3 / iters)
  198. return max(t)
  199. def say(self, msg: str) -> None:
  200. if self.rank == 0:
  201. print(f"[{self.mode}] {msg}", flush=True)
  202. def main():
  203. p = argparse.ArgumentParser()
  204. p.add_argument("--mode", choices=["mesh", "ring"], required=True)
  205. p.add_argument("--repeat", type=int, default=3, help="checks per size")
  206. p.add_argument("--bursts", type=int, default=20, help="bursts of 16 mixed collectives")
  207. args = p.parse_args()
  208. os.environ["VLLM_ARX_RING"] = "1" if args.mode == "ring" else "0"
  209. os.environ["VLLM_ARXBIG_RS"] = "1"
  210. if "GLOO_SOCKET_IFNAME" not in os.environ and "VLLM_HOST_IP" in os.environ:
  211. os.environ["GLOO_SOCKET_IFNAME"] = lan_iface(os.environ["VLLM_HOST_IP"])
  212. rank, world = int(os.environ["RANK"]), int(os.environ["WORLD_SIZE"])
  213. torch.cuda.set_device(0)
  214. dist.init_process_group("gloo", init_method=f"tcp://{os.environ['MASTER_ADDR']}:{os.environ['MASTER_PORT']}",
  215. rank=rank, world_size=world)
  216. for k in sorted(os.environ):
  217. if k.startswith(("NCCL_IB", "ARX_", "VLLM_ARX", "GLOO_")):
  218. print(f"[{args.mode}] rank {rank} {k}={os.environ[k]}", flush=True)
  219. T = Test(args.mode)
  220. KB, MB = 1 << 10, 1 << 20
  221. T.say("checking sizes")
  222. for n in (8, 64, 1024, 4096, 32768, 131072, 262144):
  223. for i in range(args.repeat):
  224. T.check(f"allreduce {n * 2} B", *T.allreduce(n, ("size", i)))
  225. for nbytes in (16, 48, 4 * KB, 1 * MB + 16, 8 * MB, 32 * MB):
  226. for i in range(args.repeat):
  227. T.check(f"all_gather {nbytes} B", *T.all_gather(nbytes, BF16, ("size", i)))
  228. T.check(f"all_gather u8 {nbytes} B", *T.all_gather(nbytes, torch.uint8, ("size", i)))
  229. for chunk in (16, 48, 4 * KB, 1 * MB + 16, 8 * MB, 32 * MB):
  230. for i in range(args.repeat):
  231. T.check(f"reduce_scatter {chunk} B/chunk", *T.reduce_scatter(chunk, ("size", i)))
  232. for Tn, Tpad, H in ((100, 64 * world, 1024), (1000, 1024, 4096), (16000, 16384, 4096)):
  233. for i in range(args.repeat):
  234. T.check(f"finalize_rs T={Tn} Tpad={Tpad} H={H}", *T.finalize_rs(Tn, Tpad, H, ("size", i)))
  235. T.say("checking bursts of mixed collectives")
  236. rng = random.Random(1234) # the same sequence on every rank
  237. sizes = {"ar": [16, 8 * KB, 64 * KB, 512 * KB], "ag": [16, 64 * KB, 1 * MB, 4 * MB],
  238. "ag8": [48, 1 * MB], "rs": [16, 64 * KB, 1 * MB, 4 * MB], "fin": [1 * MB, 8 * MB]}
  239. for b in range(args.bursts):
  240. ops = []
  241. for _ in range(16):
  242. op = rng.choice(["ar", "ar", "ar", "ag", "ag8", "rs", "rs", "fin"])
  243. ops.append((op, rng.choice(sizes[op])))
  244. T.burst(f"burst{b}", ops)
  245. T.burst("allreduce x64", [("ar", 8 * KB)] * 64)
  246. T.burst("reduce_scatter x8", [("rs", 8 * MB)] * 8)
  247. T.burst("finalize_rs x4", [("fin", 8 * MB)] * 4)
  248. T.say("all-reduce latency (arx)")
  249. for n in (4096, 32768, 131072, 262144):
  250. x = gen((n,), BF16, "bench")
  251. out = torch.empty_like(x)
  252. t = T.bench(f"allreduce {n}", lambda: T.ar.ext.allreduce(x, out, [], []), 2000)
  253. T.say(f" allreduce {n * 2 // KB:4d} KB {t * 1e6:7.1f} us")
  254. T.say("all-gather / reduce-scatter bandwidth (arxbig), data received per rank")
  255. for nbytes in (1 * MB, 8 * MB, 32 * MB):
  256. x = gen((nbytes // 2,), BF16, "bench")
  257. t = T.bench(f"all_gather {nbytes}", lambda: T.big.all_gather(x), 50)
  258. T.say(f" all_gather {nbytes // MB:3d} MB/rank {t * 1e3:7.3f} ms "
  259. f"{(world - 1) * nbytes * 8 / t / 1e9:6.1f} Gb/s")
  260. for chunk in (1 * MB, 8 * MB, 32 * MB):
  261. x = gen((chunk // 2 * world,), BF16, "bench")
  262. t = T.bench(f"reduce_scatter {chunk}", lambda: T.big.ext.reduce_scatter(x), 50)
  263. T.say(f" reduce_scatter {chunk // MB:3d} MB/chunk {t * 1e3:7.3f} ms "
  264. f"{(world - 1) * chunk * 8 / t / 1e9:6.1f} Gb/s")
  265. y, pos, w, shared = (gen((32768, 4096), BF16, "bench"), torch.randint(0, 32768, (32768,), device="cuda",
  266. dtype=torch.int32), torch.ones((16384, 2), device="cuda"), gen((16384, 4096), BF16, "bench"))
  267. def fin():
  268. T.big.ext.rs_finish(T.big.ext.moe_finalize_rs(y, pos, w, shared, 16384, 16384, None), 16384 // world, 4096)
  269. t = T.bench("finalize_rs", fin, 20)
  270. T.say(f" finalize_rs + rs_finish, 16k x 4096 bf16: {t * 1e3:.3f} ms")
  271. T.dog.at("result")
  272. fails: list = [None] * world
  273. dist.all_gather_object(fails, T.failures)
  274. T.say(f"RESULT {args.mode}: {'PASS' if sum(fails) == 0 else 'FAIL'}: {T.checks} checks per rank, "
  275. f"failures per rank {fails}")
  276. dist.barrier()
  277. os._exit(0 if sum(fails) == 0 else 1) # the proxy threads never exit
  278. if __name__ == "__main__":
  279. main()