plan.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. #!/usr/bin/env python3
  2. """Plan the hardware-forwarded opposite-node paths that let RoCEnante run on a switchless ring.
  3. python3 scripts/ring_mesh/plan.py --sparkring ~/sparkring --out ring-mesh HEAD W1 W2 W3
  4. HEAD W1 W2 W3 are SSH names of the four Sparks in tensor-parallel rank order (the head, then
  5. WORKER_HOSTS). Each is inventoried over SSH with inventory.sh; the cabling is read from the
  6. fabric subnets (each DAC is its own /24), the ring is numbered the way sparkring's planner
  7. requires (physical port f0 clockwise), and FujitsuPolycom/sparkring's own planner
  8. (spark_transport/fabric/cx7_hairpin_diagonal/fabric.py, tested at f16b5f4) builds the RoCEnante
  9. selection: per rank two /32 routes to the opposite node via a neighbour, two hardware-only tc
  10. redirect rules for the traffic it forwards, and two source markers. Writes into --out:
  11. fabric.json sparkring topology document (the plan's input)
  12. mesh-up-<host>.sh idempotent apply for that host (routes, clsact, rules, markers)
  13. mesh-down-<host>.sh removes exactly what mesh-up created
  14. env.txt the EXTRA_CONTAINER_ENV additions, with the per-rank peer maps
  15. translated from sparkring's ring numbering to the TP rank order
  16. Nothing on the hosts is changed. See docs/switchless-ring.md for installation.
  17. """
  18. import argparse
  19. import ipaddress
  20. import json
  21. import shlex
  22. import subprocess
  23. import sys
  24. from pathlib import Path
  25. HERE = Path(__file__).resolve().parent
  26. # sparkring's source-bound map: physical port f0 is the clockwise direction, function 0 is the
  27. # first PCIe domain (rocep1s0f*) and function 1 the second (roceP2p1s0f*).
  28. PORTS = {
  29. ("clockwise", 0): "enp1s0f0np0",
  30. ("clockwise", 1): "enP2p1s0f0np0",
  31. ("counter_clockwise", 0): "enp1s0f1np1",
  32. ("counter_clockwise", 1): "enP2p1s0f1np1",
  33. }
  34. HCAS = ("rocep1s0f0", "rocep1s0f1", "roceP2p1s0f0", "roceP2p1s0f1") # B12X_ROCE_HCA order
  35. def inventory(host):
  36. out = subprocess.run(["ssh", "-o", "BatchMode=yes", host, "bash -s"], input=(HERE / "inventory.sh").read_bytes(),
  37. capture_output=True, timeout=60, check=True).stdout
  38. return json.loads(out.decode())
  39. def check_host(host, inv):
  40. """Problems that stop the mesh from working on this host (empty list when fine)."""
  41. bad = []
  42. for nd, p in inv["ports"].items():
  43. if p["state"] != "up" or not p["ipv4"]:
  44. bad.append(f"{host} {nd}: link {p['state']} ip {p['ipv4'] or '-'}")
  45. if p["mtu"] != 9000:
  46. bad.append(f"{host} {nd}: MTU {p['mtu']} (9000 expected)")
  47. if p["gid3_netdev"] != nd or not p["gid3"].endswith(_gid_tail(p["ipv4"])):
  48. bad.append(f"{host} {nd}: RoCEv2 GID index 3 does not map this port's IPv4")
  49. if p["tc_offload"] != "on":
  50. bad.append(f"{host} {nd}: hw-tc-offload {p['tc_offload']}")
  51. if p["flow_steering_mode"] != "hmfs":
  52. bad.append(f"{host} {nd}: flow_steering_mode {p['flow_steering_mode']} (sparkring tested hmfs)")
  53. return bad
  54. def _gid_tail(cidr):
  55. if not cidr:
  56. return "-"
  57. a, b, c, d = str(ipaddress.ip_interface(cidr).ip).split(".")
  58. return f"{int(a):02x}{int(b):02x}:{int(c):02x}{int(d):02x}"
  59. def ring_order(invs, hosts):
  60. """Hosts in sparkring ring order, starting at the TP rank 0 host: each f0 port reaches the next host's f1."""
  61. net = {}
  62. for h in hosts:
  63. for nd, p in invs[h]["ports"].items():
  64. net.setdefault(ipaddress.ip_interface(p["ipv4"]).network, []).append((h, nd))
  65. peer = {}
  66. for ends in net.values():
  67. if len(ends) != 2:
  68. raise SystemExit(f"fabric subnet shared by {len(ends)} ports, expected one DAC per /24: {ends}")
  69. (h1, n1), (h2, n2) = ends
  70. peer[(h1, n1)], peer[(h2, n2)] = (h2, n2), (h1, n1)
  71. order = [hosts[0]]
  72. while True:
  73. cur = order[-1]
  74. nxt = []
  75. for f in (0, 1):
  76. h, nd = peer.get((cur, PORTS[("clockwise", f)]), (None, None))
  77. if nd != PORTS[("counter_clockwise", f)]:
  78. raise SystemExit(f"{cur} {PORTS[('clockwise', f)]} reaches {h} {nd}; sparkring needs every f0 port "
  79. f"cabled to the next node's f1 port on the same PCIe domain")
  80. nxt.append(h)
  81. if nxt[0] != nxt[1]:
  82. raise SystemExit(f"{cur}: its two f0 functions reach different nodes {nxt}")
  83. if nxt[0] == hosts[0]:
  84. break
  85. order.append(nxt[0])
  86. if sorted(order) != sorted(hosts) or len(order) != 4:
  87. raise SystemExit(f"not a four-node ring: {order}")
  88. return order
  89. def topology(invs, order, state_root="/run/dsv41-mesh"):
  90. ranks = []
  91. for r, h in enumerate(order):
  92. ports = {}
  93. for d in ("clockwise", "counter_clockwise"):
  94. ports[d] = []
  95. for f in (0, 1):
  96. nd = PORTS[(d, f)]
  97. p = invs[h]["ports"][nd]
  98. ports[d].append({
  99. "function": f, "netdev": nd, "rdma_device": p["rdma"],
  100. "ipv4_cidr": str(ipaddress.ip_interface(p["ipv4"]).ip) + "/32", "mac": p["mac"],
  101. "peer_rank": (r + (1 if d == "clockwise" else -1)) % 4,
  102. "peer_direction": "counter_clockwise" if d == "clockwise" else "clockwise", "peer_function": f})
  103. ranks.append({"rank": r, "ssh_alias": h, "management_netdev": "enP7s7", "ports": ports})
  104. return {"schema": "sparkring-cx7-hardware-diagonal-fabric/v1", "status": "research-only", "group_id": 77,
  105. "socket_direct_functions": 2, "flow_label_base": 16383, "shared_diagonal_flow_label": True,
  106. "endpoint_route_strategy": "adjacent_gateway", "standard_ether_type": 2048, "marked_ether_type": 34997,
  107. "standard_udp_destination_port": 4791, "roce_gid_index": 3, "bounded_runtime_seconds": 7200,
  108. "expected_ethernet_mtu": 9000, "expected_roce_mtu": 4096,
  109. "orchestration": {"host_helper_path": "/UNSUPPORTED/external-fabric-orchestrator",
  110. "remote_state_root": state_root},
  111. "ranks": ranks}
  112. def peer_maps(native_args, order, hosts):
  113. """B12X_ROCE_PEER_HCA_MAPS for the TP rank order.
  114. native_args is sparkring's rocenante_native_path_arguments(plan): per ring rank, entries
  115. "dest,function,rdma_device,gid_index,hops". Path i of a peer is its function-i path, the same
  116. physical path at both ends, which is what the proxy pairs (local path i with the peer's path i).
  117. """
  118. tp_of = {h: hosts.index(h) for h in hosts}
  119. maps = [None] * 4
  120. for ring_rank, specs in native_args.items():
  121. paths = {}
  122. for spec in specs:
  123. dest, function, rdma, _gid, _hops = spec.split(",")
  124. paths.setdefault(tp_of[order[int(dest)]], {})[int(function)] = HCAS.index(rdma)
  125. maps[tp_of[order[int(ring_rank)]]] = ",".join(f"{p}={v[0]}/{v[1]}" for p, v in sorted(paths.items()))
  126. return ";".join(maps)
  127. def host_scripts(plan, fabric, host_rank):
  128. """(mesh-up, mesh-down) for one host from sparkring's RoCEnante plan."""
  129. up = ["#!/usr/bin/env bash",
  130. "# Hardware-forwarded opposite-node paths for RoCEnante (generated by scripts/ring_mesh/plan.py). Idempotent.",
  131. "set -euo pipefail"]
  132. down = ["#!/usr/bin/env bash", "# Removes exactly what mesh-up.sh creates.", "set -uo pipefail",
  133. "systemctl stop 'dsv41-mesh-marker@*' 2>/dev/null || true"]
  134. rules = [x for x in plan.tc_rules if x.intermediate_rank == host_rank]
  135. qdiscs = sorted({x.ingress_netdev for x in rules})
  136. for q in qdiscs:
  137. up.append(f"tc qdisc show dev {q} | grep -q clsact || tc qdisc add dev {q} clsact")
  138. for x in rules:
  139. add = fabric.tc_rule_command(x, add=True)
  140. up.append(f"tc filter show dev {x.ingress_netdev} ingress pref {x.preference} | grep -q flower || {shlex.join(add)}")
  141. up.append(f"tc -s filter show dev {x.ingress_netdev} ingress pref {x.preference} | grep -q in_hw "
  142. f"|| {{ echo 'rule pref {x.preference} on {x.ingress_netdev} is not in hardware'; exit 3; }}")
  143. down.append(f"tc filter del dev {x.ingress_netdev} ingress pref {x.preference} protocol 0x88b5 "
  144. f"handle {x.handle} flower 2>/dev/null || true")
  145. for q in qdiscs:
  146. down.append(f'[ -z "$(tc filter show dev {q} ingress; tc filter show dev {q} egress)" ] '
  147. f"&& tc qdisc del dev {q} clsact 2>/dev/null || true")
  148. for r in (x for x in plan.routes if x.source_rank == host_rank):
  149. up.append(f"ip route show exact {r.destination_ipv4}/32 | grep -q . || {shlex.join(fabric.route_command(r, add=True))}")
  150. down.append(f"ip route del {r.destination_ipv4}/32 2>/dev/null || true")
  151. devs = sorted({m.rdma_device for m in plan.markers if m.source_rank == host_rank})
  152. ports = sorted({m.udp_source_port for m in plan.markers})
  153. if ports != [65535]:
  154. raise SystemExit(f"unexpected marker UDP source ports {ports}; the marker unit assumes 65535")
  155. up.append(f"for d in {' '.join(devs)}; do systemctl start dsv41-mesh-marker@$d; done")
  156. up.append(f"sleep 2; for d in {' '.join(devs)}; do systemctl is-active --quiet dsv41-mesh-marker@$d "
  157. f"|| {{ echo marker $d not active; exit 4; }}; done")
  158. up.append("echo mesh-up OK")
  159. down.append("echo mesh-down done")
  160. return "\n".join(up) + "\n", "\n".join(down) + "\n"
  161. def main():
  162. ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
  163. ap.add_argument("hosts", nargs=4, help="SSH names in TP rank order (head first)")
  164. ap.add_argument("--sparkring", required=True, help="FujitsuPolycom/sparkring checkout (tested at f16b5f4)")
  165. ap.add_argument("--out", default="ring-mesh")
  166. a = ap.parse_args()
  167. sys.path.insert(0, str(Path(a.sparkring) / "spark_transport/fabric/cx7_hairpin_diagonal"))
  168. import fabric # sparkring's planner
  169. invs = {h: inventory(h) for h in a.hosts}
  170. problems = [p for h in a.hosts for p in check_host(h, invs[h])]
  171. if problems:
  172. raise SystemExit("hosts not ready for the mesh:\n " + "\n ".join(problems))
  173. order = ring_order(invs, a.hosts)
  174. out = Path(a.out)
  175. out.mkdir(parents=True, exist_ok=True)
  176. (out / "fabric.json").write_text(json.dumps(topology(invs, order), indent=2) + "\n", newline="\n")
  177. plan = fabric.build_rocenante_plan(fabric.build_plan(fabric.load_topology(out / "fabric.json")))
  178. for r, h in enumerate(order):
  179. up, down = host_scripts(plan, fabric, r)
  180. (out / f"mesh-up-{h}.sh").write_text(up, newline="\n")
  181. (out / f"mesh-down-{h}.sh").write_text(down, newline="\n")
  182. hairpin = min(int(p["hairpin_queue_size"] or 0) for h in a.hosts for p in invs[h]["ports"].values())
  183. cap = 262144 if hairpin >= 8192 else 81920
  184. maps = peer_maps(fabric.rocenante_native_path_arguments(plan), order, list(a.hosts))
  185. env = (f"DSV41_ROCE_RING=1 SGLANG_ROCE_ALLREDUCE=1 SGLANG_ROCE_MAX_SIZE={cap} DSV41_ROCE_GATHER={cap} "
  186. f"B12X_ROCE_HCA={','.join(HCAS)} B12X_ROCE_PEER_HCA_MAPS={maps} B12X_ROCE_TWO_WAVE_THRESHOLD_BYTES=0")
  187. (out / "env.txt").write_text(env + "\n", newline="\n")
  188. print(f"ring order (sparkring numbering): {' -> '.join(order)} -> {order[0]}")
  189. print(f"hairpin_queue_size (smallest): {hairpin} -> RoCE size cap {cap} bytes")
  190. print(f"wrote {out}/fabric.json, mesh-up-*.sh, mesh-down-*.sh, env.txt\nEXTRA_CONTAINER_ENV additions:\n {env}")
  191. if __name__ == "__main__":
  192. main()