| name | cluster-network-topology |
| description | Discover, diagram, and diagnose the RDMA/GPU network topology of a GPU cluster (rails, NICs, GID/RoCE config, GPU<->NIC PCIe affinity), determine whether the fabric supports cross-rail communication, and localize a failure to a fabric tier (NIC / leaf / spine) using only unprivileged host-side probes. Picks up where `mori check` stops: `mori check` answers "is this host configured correctly?" and drives its peer over SSH, while this answers "what is the fabric, and which tier broke?" from inside a scheduler allocation where SSH to compute nodes is unavailable. Handles IPv4-mapped AND IPv6-ULA RoCE addressing and classifies at BOTH the IP and RDMA layers — a fabric can be IP-routable cross-rail yet rail-only for RDMA. Includes the addressing-plan decode that recovers rail/leaf/pod grouping without switch access, spine enumeration that settles shared-vs-partitioned upper tiers from an unprivileged shell, a confound register for the measurement tools themselves, and an sbatch harness for multi-node tests under Slurm/Spur. Use when asked to "map the cluster", "draw how NICs are connected", "cross-rail diagram", "can we enable cross-rail RDMA", "get the switch topology", or to diagnose RDMA/RoCE QP-setup failures (e.g. rc=110 ETIMEDOUT on INIT->RTR), cross-rail unreachability, rail affinity, or EP / KV-transfer connectivity problems on AMD (ionic/bnxt/mlx5) or NVIDIA (mlx5) fabrics. |
Cluster network topology
Companion to mori check (tools/env_check.sh) and the deploy-mori skill: those
answer "is this host configured correctly?", this answers "what is the fabric, and which
tier broke?". The scripts referenced throughout ship beside this file —
rail_detect.sh, probe_topology.sh, xrail_worker.sh, xrail_worker.sbatch,
xrail_matrix.sh, xrail_matrix.sbatch, make_diagrams.py, make_report.py.
rail_detect.sh is the shared rail-discovery library the other scripts source: it
auto-detects each device's global RoCEv2 GID index and address (IPv4-mapped or IPv6
ULA/GUA, skipping fe80:: link-local), so the tools never disagree about the same
machine. Run it alone to see what a node looks like: bash rail_detect.sh --dump.
A "rail-optimized" GPU cluster gives each GPU its own NIC, and each NIC lives on its
own isolated L2/L3 domain (a "rail"). Rails may or may not be routable to each other:
- Full-mesh fabric: any NIC can reach any NIC (cross-rail works) at both IP and RDMA.
- Rail-only fabric: a NIC can only reach the same rail on other nodes; cross-rail
is unroutable. Symptom: same-rail RDMA works, cross-rail QP
INIT->RTR fails
(ibv_modify_qp returns ETIMEDOUT/110, or ping shows 100% loss cross-rail).
- IP-routable but RDMA rail-only (seen in the wild): cross-rail ICMP/IP works
(the rails are routed), but cross-rail RoCEv2 RDMA still fails. So do not classify
from ping alone — confirm at the RDMA layer (Step 2).
The goal of this skill is to (1) discover the layout, (2) classify the fabric at both
layers, (3) localize a failure to a tier and say what could change it, and
(4) produce a diagram + report + guidance for RDMA workloads.
Measurement discipline (read before running anything)
Three rules, each learned by getting it wrong. They cost more time to re-do than to
follow.
1. Every negative needs a positive control from the same tool, in the same job, at
the same instant. A cross-rail FAIL means nothing on its own — the peer might be
down, the server might not have bound, the GID index might be wrong. It means something
next to a same-rail OK from the same binary, same allocation, same minute. Sweeps
(service level, DSCP/traffic class, MTU) are the usual offenders: a sweep where every
value fails and no control was run is indistinguishable from a broken harness and
carries zero evidential weight. Build the control into the harness, not into a
follow-up run.
2. Never let one tool be the sole source of a verdict. Tools embed assumptions in
their address handles, and a fabric-shaped conclusion drawn from one binary is really a
conclusion about that binary. See the confound register in Step 2c — the canonical case
is ibv_rc_pingpong, which hardcodes hop_limit = 1 and never uses RDMA CM. Before
reporting "the fabric cannot do X", reproduce X's failure with at least two tools that
build their path differently (one hand-built AH, one RDMA CM).
3. Capture the cheap artifacts every run, whether or not you think you need them.
ip -6 route show table all, ip -6 neigh, ip -o addr, /sys/class/net/*/statistics,
and the tool inventory cost milliseconds and are the first things you will wish you had
when the verdict is questioned weeks later. ip route is the single most often
forgotten and most load-bearing artifact — it decides whether a rail plane is on-link
(pure L2) or routed, which in turn decides whether the hop_limit confound is fatal or
harmless. If you get one allocation, get the routes.
Corollary: record the confounds you did not close in the report itself. A verdict
with a named, dated open question is usable; a verdict that quietly rests on an
unexamined assumption is a liability.
Step 1 — Discover the layout
Prefer the bundled probe_topology.sh (auto-detects NICs, GIDs incl. IPv6-ULA rails,
GPUs, GPU model, same-PCI-domain affinity; emits topo_report.<host>.txt plus a raw
topology_<host>_node.auto.{mmd,dot}). Run it inside the
container/host that owns the devices:
./probe_topology.sh
PEER_IPS='<rail0_ip> <rail1_ip> ...' ./probe_topology.sh --peer <host>
GID_INDEX=1 ./probe_topology.sh
--peer needs PEER_IPS (the peer's addresses in rail order) because deriving them
would mean guessing the site's addressing plan; without it the section says so rather
than printing an empty pass. It is IP only — prefer xrail_worker.sbatch, which
discovers both sides itself and tests the RDMA layer too.
If doing it by hand, collect these facts per node:
| Fact | Where |
|---|
| RDMA devices | ls /sys/class/infiniband (or ibv_devices) |
| Port state / link layer / rate | /sys/class/infiniband/<dev>/ports/1/{state,rate}, ibv_devinfo |
| RoCE GID + type + addr | /sys/class/infiniband/<dev>/ports/1/gids/<i> and .../gid_attrs/types/<i>. Pick the RoCEv2 global entry — this may be IPv4-mapped (::ffff:AABBCCDD, tail = rail IP) or a global IPv6, commonly a ULA fc00::/7 (each rail its own /64). Skip fe80:: link-local. Note the GID index (RDMA apps need it: NCCL_IB_GID_INDEX, MORI_IB_GID_INDEX). |
| netdev per NIC | .../gid_attrs/ndevs/<i> (e.g. enP2p0s9) |
| Rail IP (v4 or v6) | IPv4 from the ::ffff: tail; for IPv6 rails read ip -o -6 addr show <ndev> scope global. If a rail has no IPv4 and only a fe80:: GID, look again — its routable address is usually an IPv6 ULA at a higher GID index (this is easy to miss). |
| MTU per netdev | cat /sys/class/net/<ndev>/mtu. Rails are usually jumbo (9000); a mgmt NIC usually is not. A 1500-MTU path still works, so this is easy to miss while it quietly taxes throughput. |
| L3 shape | ip -6 route show table all, ip -4 route show table all, ip route show default, ip -6 neigh show. See Step 3a — this is what tells you whether a rail is on-link or routed. |
| NIC PCI bus + NUMA | readlink -f /sys/class/infiniband/<dev>/device; .../device/numa_node |
| Firmware + driver | cat /sys/class/infiniband/<dev>/fw_ver; modinfo <mod> vs /sys/module/<mod>/version. Compare as a tuple, not lexically — a larger trailing build number can hide a smaller patch number (1.117.1-a-63 is older than 1.117.5-a-58). |
| GPU PCI bus + model | rocm-smi --showbus / rocm-smi --showproductname (AMD, verify the actual model — don't infer from PCI DID) or nvidia-smi --query-gpu=index,pci.bus_id,name (NVIDIA) |
GPU↔NIC affinity. PCI addresses are domain:bus:dev.func (e.g. 0002:00:01.0).
First isolate the rail NICs — drop the management/front-end NIC(s): the one on the
default route (ip route show default) and any RDMA device with no global address. This
matters on single-PCI-domain boxes where mgmt NICs are interspersed among the rails
(e.g. mlx5 eth0/eth1 sitting among rdma0..7); if you don't drop them the pairing
shifts. Then pair within the same PCI domain by ordinal: sort the rail NICs and the
GPUs in each domain by full PCI address and zip them (k-th GPU ↔ k-th NIC). A naive
"nearest bus number" instead collapses on multi-domain boxes (the bus field is 00 for
everything). On a rail-optimized box the two halves land on the two NUMA nodes / PCI
domains (first-half GPUs with first-half NICs, second half with second half); on
single-domain boxes take the ordinal over the rails.
Note whether the mgmt NIC is cross-socket for half the GPUs — it usually is, and it
matters if you later fall back to it (Step 5).
Script portability. The probe must run where the devices live. Two gotchas the
bundled script already handles, but watch for if you hand-roll it:
- No
/dev/fd in some container / scheduler-step namespaces → bash process
substitution (while read … < <(cmd)) fails with /dev/fd/63: No such file. Read
from a temp file instead.
- Parse the full PCI address (domain included), per the affinity note above.
Step 2 — Classify the fabric (both layers)
Pick two nodes A and B. Test per rail, both layers, and as a full N×N matrix
rather than a couple of spot checks — the shape of the matrix is the finding.
2a — The N×N matrix
For an N-rail node, run all N² source-rail × destination-rail combinations at both
layers. What you are looking for is the pattern:
| Matrix shape | Meaning |
|---|
| All N² pass | Full mesh. |
| Perfect diagonal (N pass, N²−N fail) | Rail-partitioned for that tool. The clean diagonal is itself the positive control — it proves the harness, the GID index, the ports and the peer are all fine. |
| Ragged / asymmetric | Not a fabric property. Suspect the harness, a wedged server, a per-device config difference, or a sick NIC. Re-run before interpreting. |
| Diagonal fails too | Wrong GID index, wrong port, peer down. Fix this before reading anything else. |
IP layer (quick proxy). Bind to the rail's netdev. ping -I <ifname> sets
SO_BINDTODEVICE, which genuinely forces egress out that device — so a cross-rail ping
success is real evidence that L3 crosses planes. Caveat: -I only forces the
outbound path. The return path is chosen by the far node and is not observed; a
success therefore proves "there is a route out and some route back", not "the reverse
traversed the same rail".
ping -c2 -I <ethX_railN> <B_ip4_railN>
ping -6 -c2 -I <ethX_railN> <B_ip6_railN>
ping -6 -c2 -I <ethX_railN> <B_ip6_railM>
Confirm the packets really left the rail NIC rather than leaking to the default route:
cat /sys/class/net/<ndev>/statistics/tx_packets
ping -6 -c 20 -I <ndev> <peer cross-rail addr>
cat /sys/class/net/<ndev>/statistics/tx_packets
RDMA layer (authoritative). ICMP passing cross-rail does not prove RDMA works.
Server on B, client on A, using the chosen RoCEv2 GID index (-g / -x):
B$ ibv_rc_pingpong -d <dev0> -g <gid> -p 18500
A$ ibv_rc_pingpong -d <dev0> -g <gid> -p 18500 <B_host>
B$ ibv_rc_pingpong -d <dev1> -g <gid> -p 18501
A$ ibv_rc_pingpong -d <dev0> -g <gid> -p 18501 <B_host>
The out-of-band handshake goes over the management network by hostname; the RDMA path
follows -d/-g. Note ibv_rc_pingpong exits after one connection, so the server
side must re-listen in a loop for a matrix run; the destination rail is selected by which
server port you connect to, the source rail by the client's -d.
Classification:
- Same-rail RDMA OK and cross-rail RDMA fails ⇒ rail-only for RDMA (the important
case) — regardless of whether ICMP crossed. But see 2c before writing it down.
- Cross-rail RDMA also OK ⇒ full-mesh.
- To see the real errno when a library asserts, enable the provider's debug (e.g. AMD
ionic:
IONIC_DEBUG=1 IONIC_DEBUG_FILE=/tmp/ionic_dbg; look for
modify qp ... state 1 -> 2 rc <errno>, where state 1->2 = INIT->RTR).
2b — The paired IP-vs-RDMA control
Run both layers against the same node pair, in the same job, back to back. This is
the single highest-value experiment in the whole skill, because the interesting fabrics
are the ones where the two layers disagree, and a disagreement measured hours apart on
different node pairs is not a disagreement — it is two unrelated observations.
Emit one row per (src rail, dst rail) with both verdicts side by side:
rail0 -> rail0 : RDMA OK IP OK
rail0 -> rail1 : RDMA FAIL IP OK <- this row is the finding
2c — Confound register: the tool is part of the experiment
Before a matrix result becomes a fabric verdict, check what the tool did to the path:
| Tool | Path construction | Hop limit | Confound |
|---|
ibv_rc_pingpong | hand-built AH, TCP out-of-band | source says grh.hop_limit = 1 | 1 would permit zero router hops, which alone could reproduce a perfect diagonal with no fabric involvement. But the kernel overwrites it from the route, so in practice it is usually not in force — measured: pingpong succeeds across a 3-router path. Settle it by hop count (below), not by reading the source. |
ib_write_bw / ib_write_lat (perftest) | hand-built AH, TCP out-of-band | sets a high hop limit | Not subject to the above, but still never consults RDMA CM. Good second opinion. |
ib_write_bw -R, rping, ucmatose | RDMA CM (rdma_resolve_addr/rdma_resolve_route) | from the kernel route | Uses the same resolution path a real library uses. If cross-rail works here and not with a hand-built AH, the fabric is fine and your address handle was wrong. |
ping -I | kernel route + SO_BINDTODEVICE | kernel default (64) | Egress forced, return path unobserved. |
Closing the hop-limit confound cheaply. You do not need a hop-limit sweep, and a
sweep is hard to run correctly anyway (a failed run leaves the peer's server mid-timeout,
so the next cell fails for harness reasons and the column fills with noise). Instead
count the router hops on the path the tool already succeeded on. If the same-rail
run passes and mtr/traceroute shows that same-rail crosses one or more routers, then
a hop limit of 1 was demonstrably never in force — whatever the source says — and it
cannot explain the cross-rail failures. One mtr run retires the whole question.
Minimum bar for a "cross-rail is dead" verdict: the failure reproduces under at least
two tools that build the path differently, each with a same-rail positive control in the
same job, and ip route has been captured so the on-link-vs-routed question is settled.
Prefer a second hand-built-AH tool over an RDMA CM tool if the CM tools cannot be made to
source-bind — CM will not pick a cross-rail path on its own anyway (see 3c).
B$ rping -s -a <B rail1 addr>
A$ rping -c -a <B rail1 addr> -C 10
A$ ib_write_bw -R <B rail1 addr>
2d — Running multi-node tests under a scheduler (Slurm / Spur)
You need coordinated processes on two different nodes. Pitfalls learned the hard way:
- Interactive
srun --overlap from the login node lands on the first node only
(even with -w / --nodelist / -N2 -n2), and direct SSH to compute nodes is often
blocked (publickey-only, key not installed). So drive it from a batch job, whose
steps do spread across the allocation. This is also why mori check, which drives
its mesh over SSH, cannot be used directly on such clusters.
- Spur-like schedulers:
srun may be non-blocking (returns on dispatch, so the
batch script must wait on its own sentinel files or it falls off the end and kills the
workers); srun may not propagate the submitter's environment (pass knobs via
files in a shared run dir); scontrol show hostnames may be unsupported and
--ntasks-per-node rejected. Don't expand the nodelist — where srun exists, launch
srun -N2 -n2 --overlap; either way have the workers self-organize by $(hostname)
(sort the names; lower = tester A, higher = target B), which is what makes the same
worker correct under both fan-out models below. The batch script may run from a spool copy,
so use $SLURM_SUBMIT_DIR, not $BASH_SOURCE.