arx.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. """All-reduce over RoCE for GB10 tensor-parallel groups, without NCCL.
  2. NCCL costs about 80-90 us per decode-sized all-reduce on these boxes; this
  3. costs 13 us at 8 KB and 27 us at 64 KB. Each rank writes its partial to its
  4. peers with RDMA over both ConnectX roots (see arx_vllm.cu). Larger all-reduces,
  5. which are prefill-sized, stay on NCCL, whose bandwidth wins there.
  6. Enabled with VLLM_ARX_ALLREDUCE=1. The RDMA devices and GID index come from
  7. NCCL_IB_HCA (exactly two, one per root, in the same subnet order on every
  8. rank) and NCCL_IB_GID_INDEX.
  9. Ring mode (VLLM_ARX_RING=1) is for groups of 2, 3 or 4 cabled in a ring with
  10. no switch, with rank r's "next" port cabled to rank r+1. arx and arxbig open
  11. QPs only to r-1 and r+1, and at 4 the rank in between relays raw data for the
  12. rank beyond, so results match mesh mode bit for bit (see the .cu files). At 3
  13. both other ranks are neighbours and nothing is relayed. arxbig's ring
  14. reduce-scatter needs 2 or 4 ranks, so a ring of 3 runs arx alone
  15. (VLLM_ARXBIG=0). ARX_RING_PREV_HCAS and ARX_RING_NEXT_HCAS name the two RDMA
  16. devices of the port facing each neighbour, in the order whose root r shares a
  17. subnet with root r at that neighbour: root 0 then root 1 when every box is
  18. cabled the same way, the other order on a box whose cabling is mirrored. Each
  19. port has its own subnet, so each device uses the RoCE v2 GID of the IPv4
  20. address on its own netdev.
  21. """
  22. import os
  23. import torch
  24. import torch.distributed as dist
  25. from torch.distributed import ProcessGroup
  26. from vllm.logger import init_logger
  27. logger = init_logger(__name__)
  28. _ext = None
  29. _taken = False # the extension holds one connection per process
  30. def _hcas(var: str) -> list[str]:
  31. return [h.strip("=^").split(":")[0] for h in os.environ.get(var, "").split(",") if h]
  32. def roce_v2_gid(dev: str) -> int:
  33. """GID index of dev's RoCE v2 GID for an IPv4 address on dev's own netdev.
  34. The kernel fills the GID table from the netdev's addresses, and ndevs
  35. names the netdev an entry came from, so a VLAN or other upper device's
  36. entries are skipped.
  37. """
  38. base = f"/sys/class/infiniband/{dev}"
  39. netdevs = set(os.listdir(f"{base}/device/net"))
  40. port = f"{base}/ports/1"
  41. found = []
  42. for name in sorted(os.listdir(f"{port}/gids"), key=int):
  43. try:
  44. with open(f"{port}/gids/{name}") as f:
  45. gid = f.read().strip()
  46. with open(f"{port}/gid_attrs/types/{name}") as f:
  47. kind = f.read().strip()
  48. with open(f"{port}/gid_attrs/ndevs/{name}") as f:
  49. ndev = f.read().strip()
  50. except OSError: # empty entries refuse to report their attributes
  51. continue
  52. if kind == "RoCE v2" and ndev in netdevs and gid.startswith("0000:0000:0000:0000:0000:ffff:"):
  53. found.append(int(name))
  54. if not found:
  55. raise RuntimeError(f"arx: {dev} has no RoCE v2 GID for an IPv4 address on {sorted(netdevs)}")
  56. if len(found) > 1:
  57. logger.warning("arx: %s has IPv4 GIDs at %s; using %d", dev, found, found[0])
  58. return found[0]
  59. def fabric(world: int, who: str):
  60. """(devices, GID index per device, ring) for this rank, or None to use NCCL."""
  61. if os.environ.get("VLLM_ARX_RING") == "1":
  62. prev, nxt = _hcas("ARX_RING_PREV_HCAS"), _hcas("ARX_RING_NEXT_HCAS")
  63. if len(prev) != 2 or len(nxt) != 2:
  64. logger.warning("%s ring mode needs two devices each in ARX_RING_PREV_HCAS and ARX_RING_NEXT_HCAS, "
  65. "got %r and %r; using NCCL", who, prev, nxt)
  66. return None
  67. if world not in (2, 3, 4):
  68. raise ValueError(f"{who} ring mode supports groups of 2 to 4 ranks, not {world}")
  69. devs = prev + nxt
  70. return devs, [roce_v2_gid(d) for d in devs], True
  71. hcas = _hcas("NCCL_IB_HCA")
  72. if len(hcas) != 2:
  73. logger.warning("%s needs two RDMA devices in NCCL_IB_HCA, got %r; using NCCL", who, hcas)
  74. return None
  75. gid = int(os.environ.get("NCCL_IB_GID_INDEX", "5"))
  76. return hcas, [gid, gid], False
  77. def _jit_load(name, *args, **kwargs):
  78. """torch's cpp_extension.load(), safe after a build was killed midway (#53).
  79. torch marks a build with a `lock` file and waits forever on one it finds, so a
  80. build killed midway hangs every later load with nothing in the log. Builds here
  81. also hold an flock, which the kernel drops when its holder dies: whoever holds
  82. it is the only builder, and any `lock` file it finds is stale."""
  83. import fcntl
  84. from torch.utils.cpp_extension import _get_build_directory, load
  85. build = _get_build_directory(name, False)
  86. with open(os.path.join(build, "lock.flock"), "w") as f:
  87. fcntl.flock(f, fcntl.LOCK_EX)
  88. try:
  89. os.remove(os.path.join(build, "lock"))
  90. except FileNotFoundError:
  91. pass
  92. return load(name, *args, **kwargs)
  93. def _load():
  94. global _ext
  95. if _ext is None:
  96. _ext = _jit_load(
  97. "arx_vllm",
  98. [os.path.join(os.path.dirname(__file__), "arx_vllm.cu")],
  99. extra_cuda_cflags=["-O3", "-std=c++17", "-gencode=arch=compute_121a,code=sm_121a"],
  100. extra_ldflags=["-libverbs"],
  101. )
  102. return _ext
  103. # (address, bytes) ranges for the next all-reduce to prefetch into L2 while it waits.
  104. _prefetch: tuple = ()
  105. def set_prefetch(tensors) -> None:
  106. """The next arx all-reduce asks L2 for these tensors' bytes while it waits for peers."""
  107. global _prefetch
  108. _prefetch = tuple((t.data_ptr(), t.numel() * t.element_size()) for t in tensors if t is not None)
  109. class ArxCommunicator:
  110. def __init__(self, group: ProcessGroup, device: torch.device):
  111. global _taken
  112. self.disabled = True
  113. if _taken:
  114. logger.warning("arx already serves another group in this process; using NCCL here")
  115. return
  116. rank, world = dist.get_rank(group), dist.get_world_size(group)
  117. fab = fabric(world, "arx")
  118. if fab is None:
  119. return
  120. devs, gids, ring = fab
  121. with torch.cuda.device(device):
  122. ext = _load()
  123. info = ext.prepare(rank, world, devs, gids, ring)
  124. infos: list[bytes] = [b""] * world
  125. dist.all_gather_object(infos, info, group=group)
  126. ext.connect(infos)
  127. dist.barrier(group=group)
  128. _taken = True
  129. self.ext = ext
  130. self.max_bytes = min(int(os.environ.get("VLLM_ARX_MAX_BYTES", 256 << 10)), ext.max_bytes())
  131. # Two-shot (reduce-scatter + all-gather) for all-reduces above max_bytes,
  132. # up to this size: 2-2.7x faster than NCCL from 512 KB. Mesh only; 0 is off.
  133. self.two_shot_max = min(int(os.environ.get("VLLM_ARX_TWO_SHOT_MAX_KB", "0")) << 10, ext.max_bytes2())
  134. self.world = world
  135. self.disabled = False
  136. logger.info("arx all-reduce: rank %d/%d%s on %s, gids %s, up to %d bytes%s", rank, world,
  137. " ring (prev, next)" if ring else "", devs, gids, self.max_bytes,
  138. f", two-shot up to {self.two_shot_max} bytes" if self.two_shot_max else "")
  139. def should_use(self, t: torch.Tensor, two_shot: bool = False) -> bool:
  140. """Whether arx takes t: up to max_bytes, or up to two_shot_max when the caller allows it."""
  141. if not (t.dtype == torch.bfloat16 and t.is_cuda and t.is_contiguous() and t.numel() % 8 == 0):
  142. return False
  143. nbytes = t.numel() * 2
  144. return nbytes <= self.max_bytes or (
  145. two_shot and nbytes <= self.two_shot_max and t.numel() % (8 * self.world) == 0)
  146. def all_reduce(self, t: torch.Tensor) -> torch.Tensor:
  147. global _prefetch
  148. out = torch.empty_like(t)
  149. if t.numel() * 2 > self.max_bytes:
  150. self.ext.allreduce2(t, out)
  151. return out
  152. pf, _prefetch = _prefetch, ()
  153. self.ext.allreduce(t, out, [p for p, _ in pf], [b for _, b in pf])
  154. return out
  155. _big_ext = None
  156. class ArxBig:
  157. """Prefill-sized all-gathers over RoCE (arxbig.cu): ~10% faster than NCCL's.
  158. Enabled with VLLM_ARXBIG=1 next to arx. all_gather returns a view of a
  159. pinned buffer that the fourth all-gather after it overwrites, so a caller
  160. that keeps the result longer must copy it.
  161. """
  162. def __init__(self, group: ProcessGroup, device: torch.device):
  163. global _big_ext
  164. self.disabled = True
  165. self.rs = False
  166. self.gather = False
  167. rank, world = dist.get_rank(group), dist.get_world_size(group)
  168. fab = fabric(world, "arxbig")
  169. if fab is None:
  170. return
  171. devs, gids, ring = fab
  172. slot = int(os.environ.get("VLLM_ARXBIG_SLOT_MB", "132")) << 20
  173. slot -= slot % (world * 16)
  174. with torch.cuda.device(device):
  175. if _big_ext is None:
  176. _big_ext = _jit_load("arxbig", [os.path.join(os.path.dirname(__file__), "arxbig.cu")],
  177. extra_cuda_cflags=["-O3", "-std=c++17", "-gencode=arch=compute_121a,code=sm_121a"],
  178. extra_ldflags=["-libverbs"])
  179. self.rs = os.environ.get("VLLM_ARXBIG_RS") == "1"
  180. info = _big_ext.prepare(rank, world, devs, gids, ring, slot, slot if self.rs else 0)
  181. infos: list[bytes] = [b""] * world
  182. dist.all_gather_object(infos, info, group=group)
  183. _big_ext.connect(infos)
  184. dist.barrier(group=group)
  185. self.ext, self.world, self.slot = _big_ext, world, slot
  186. self.min_bytes = int(os.environ.get("VLLM_ARXBIG_MIN_KB", "1024")) << 10
  187. self.gather = os.environ.get("VLLM_ARXBIG_AG") == "1"
  188. self.disabled = False
  189. logger.info("arxbig all-gather: rank %d/%d%s, %d MiB slots", rank, world, " ring" if ring else "", slot >> 20)
  190. def should_gather(self, t: torch.Tensor) -> bool:
  191. total = t.numel() * t.element_size() * self.world
  192. return (t.is_cuda and t.dim() >= 1 and (t.numel() * t.element_size()) % 16 == 0
  193. and self.min_bytes <= total <= self.slot)
  194. def all_gather(self, t: torch.Tensor) -> torch.Tensor:
  195. return self.ext.all_gather(t.contiguous())