doctor checks a node alone, and every check can pass while two nodes still cannot reach each other. ctl fabric-test A B asks B's agent for an ib_write_bw server and A's for the client, adapter by adapter as NCCL pairs them, one rail at a time, and grades each against its link's rate. It goes through the agents' streams, so there is no SSH between nodes; the agent installs perftest the first time, and names the RoCE v2 GID itself. The result is read from perftest's table by its heading, not a column number. Agents now list the requests they answer beyond the usual (Register.features), and the control plane refuses a test on one too old to answer instead of waiting minutes for it. The forwarding dispatcher carries the new capability, and its test says so: a missing forward would have made the command quietly unavailable in production. Not yet run over real InfiniBand or RoCE; this cluster has neither. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
236 lines
7.2 KiB
Go
236 lines
7.2 KiB
Go
// Package fabrictest measures the fabric between two nodes, rail by rail.
|
||
//
|
||
// Every check on one node — ports up, a subnet manager, the right modules —
|
||
// can pass while the two nodes still cannot reach each other: a cable into the
|
||
// wrong switch, a partition key, a RoCE network with no route. A replica
|
||
// spanning them then starts, and hangs on its first all-reduce with nothing to
|
||
// say why. This runs what that all-reduce would ride on — an RDMA write from
|
||
// each adapter to its counterpart — and says how it went against the link's
|
||
// rate, before a replica does.
|
||
package fabrictest
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"slices"
|
||
"strings"
|
||
"time"
|
||
|
||
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
|
||
)
|
||
|
||
// Asker sends one side of a test to a node and waits for its answer.
|
||
type Asker interface {
|
||
FabricTest(ctx context.Context, node string, req *llmgwv1.FabricTestRequest) (*llmgwv1.FabricTestReply, error)
|
||
}
|
||
|
||
// Rail is one adapter on each node, paired by their position in the fabric's
|
||
// declaration: mlx5_0 with mlx5_0, the way NCCL pairs them.
|
||
type Rail struct {
|
||
A, B string // adapters
|
||
PortA, PortB int
|
||
// RateGbps is the slower of the two links.
|
||
RateGbps int
|
||
}
|
||
|
||
// Result is one rail's test.
|
||
type Result struct {
|
||
Rail
|
||
Gbps float64 `json:"gbps"`
|
||
// Verdict is "ok" at 80% of the link's rate or more, "slow" at 50%,
|
||
// "fail" below that or when it did not run.
|
||
Verdict string `json:"verdict"`
|
||
Detail string `json:"detail,omitempty"`
|
||
}
|
||
|
||
// Report is a whole test.
|
||
type Report struct {
|
||
Fabric string `json:"fabric"`
|
||
A, B string `json:"-"`
|
||
Results []Result `json:"results"`
|
||
// Notes say what the test could not cover.
|
||
Notes []string `json:"notes,omitempty"`
|
||
}
|
||
|
||
// OK reports whether every rail passed.
|
||
func (r Report) OK() bool {
|
||
for _, x := range r.Results {
|
||
if x.Verdict != "ok" {
|
||
return false
|
||
}
|
||
}
|
||
return len(r.Results) > 0
|
||
}
|
||
|
||
// Plan pairs the adapters two nodes have on a fabric. With no fabric named,
|
||
// the one fabric they share is used.
|
||
func Plan(a, b inventory.Node, fabric string) (string, []Rail, []string, error) {
|
||
if a.ID == b.ID {
|
||
return "", nil, nil, errors.New("give two different nodes")
|
||
}
|
||
if fabric == "" {
|
||
var common []string
|
||
for _, f := range a.FabricNames() {
|
||
if b.HasFabric(f) {
|
||
common = append(common, f)
|
||
}
|
||
}
|
||
switch len(common) {
|
||
case 0:
|
||
return "", nil, nil, fmt.Errorf("%s (%s) and %s (%s) declare no fabric in common",
|
||
a.ID, orNone(a.FabricNames()), b.ID, orNone(b.FabricNames()))
|
||
case 1:
|
||
fabric = common[0]
|
||
default:
|
||
return "", nil, nil, fmt.Errorf("%s and %s share %s: name one with --fabric", a.ID, b.ID, strings.Join(common, ", "))
|
||
}
|
||
}
|
||
for _, n := range []inventory.Node{a, b} {
|
||
if !slices.Contains(n.Features, "fabric-test") {
|
||
return "", nil, nil, fmt.Errorf("%s's agent (%s) is too old to run a bandwidth test: upgrade it", n.ID, orNone([]string{n.AgentVersion}))
|
||
}
|
||
}
|
||
ha, hb := a.HCAsFor(fabric), b.HCAsFor(fabric)
|
||
if len(ha) == 0 || len(hb) == 0 {
|
||
return "", nil, nil, fmt.Errorf("%s is not declared on both %s and %s", fabric, a.ID, b.ID)
|
||
}
|
||
var notes []string
|
||
if len(ha) != len(hb) {
|
||
notes = append(notes, fmt.Sprintf("%s has %d adapters on %s and %s has %d: only the first %d are paired",
|
||
a.ID, len(ha), fabric, b.ID, len(hb), min(len(ha), len(hb))))
|
||
}
|
||
var rails []Rail
|
||
for i := range min(len(ha), len(hb)) {
|
||
pa, ra := port(a, ha[i])
|
||
pb, rb := port(b, hb[i])
|
||
rails = append(rails, Rail{A: ha[i], B: hb[i], PortA: pa, PortB: pb, RateGbps: minPositive(ra, rb)})
|
||
}
|
||
return fabric, rails, notes, nil
|
||
}
|
||
|
||
// port is an adapter's first active port and its rate, or port 1 when the
|
||
// node reported none.
|
||
func port(n inventory.Node, hca string) (int, int) {
|
||
for _, p := range n.IBPorts {
|
||
if p.HCA == hca && strings.EqualFold(p.State, "ACTIVE") {
|
||
return p.Port, p.RateGbps
|
||
}
|
||
}
|
||
return 1, 0
|
||
}
|
||
|
||
// Options for a run.
|
||
type Options struct {
|
||
Seconds int
|
||
// BasePort is perftest's TCP exchange port for the first rail; each rail
|
||
// takes the next, so a rail whose server is still closing never meets the
|
||
// next one's.
|
||
BasePort int
|
||
// Settle is how long the server is given to listen before the client
|
||
// dials; the client retries besides.
|
||
Settle time.Duration
|
||
}
|
||
|
||
// Run tests every rail, one after another: rails at once would share the
|
||
// PCIe and memory bandwidth they are measuring.
|
||
func Run(ctx context.Context, ask Asker, a, b inventory.Node, fabric string, rails []Rail, o Options) Report {
|
||
if o.Seconds <= 0 {
|
||
o.Seconds = 3
|
||
}
|
||
if o.BasePort == 0 {
|
||
o.BasePort = 18515
|
||
}
|
||
rep := Report{Fabric: fabric, A: a.ID, B: b.ID}
|
||
for i, r := range rails {
|
||
rep.Results = append(rep.Results, runRail(ctx, ask, a, b, r, o, o.BasePort+i))
|
||
}
|
||
return rep
|
||
}
|
||
|
||
func runRail(ctx context.Context, ask Asker, a, b inventory.Node, r Rail, o Options, tcp int) Result {
|
||
res := Result{Rail: r, Verdict: "fail"}
|
||
if b.MgmtAddr == "" {
|
||
res.Detail = b.ID + " reported no management address for the client to dial"
|
||
return res
|
||
}
|
||
type answer struct {
|
||
r *llmgwv1.FabricTestReply
|
||
err error
|
||
}
|
||
server := make(chan answer, 1)
|
||
go func() {
|
||
rep, err := ask.FabricTest(ctx, b.ID, &llmgwv1.FabricTestRequest{Role: "server", Hca: r.B,
|
||
Port: int32(r.PortB), TcpPort: int32(tcp), Seconds: int32(o.Seconds)}) //nolint:gosec // small numbers
|
||
server <- answer{rep, err}
|
||
}()
|
||
select {
|
||
case <-ctx.Done():
|
||
res.Detail = ctx.Err().Error()
|
||
return res
|
||
case <-time.After(o.Settle):
|
||
}
|
||
client, err := ask.FabricTest(ctx, a.ID, &llmgwv1.FabricTestRequest{Role: "client", Hca: r.A,
|
||
Port: int32(r.PortA), Peer: b.MgmtAddr, TcpPort: int32(tcp), Seconds: int32(o.Seconds)}) //nolint:gosec // small numbers
|
||
var srv answer
|
||
select {
|
||
case srv = <-server:
|
||
case <-time.After(time.Duration(o.Seconds)*time.Second + time.Minute):
|
||
srv.err = errors.New("the server side did not finish")
|
||
}
|
||
switch {
|
||
case err != nil:
|
||
res.Detail = a.ID + ": " + err.Error()
|
||
case client.GetError() != "":
|
||
res.Detail = a.ID + ": " + client.GetError()
|
||
// The server's reason is often the better one: it saw no one arrive.
|
||
if srv.r != nil && srv.r.GetError() != "" {
|
||
res.Detail += "; " + b.ID + ": " + srv.r.GetError()
|
||
}
|
||
default:
|
||
res.Gbps = client.GetGbps()
|
||
res.Verdict, res.Detail = grade(res.Gbps, r.RateGbps)
|
||
}
|
||
return res
|
||
}
|
||
|
||
// grade compares a measurement with the link's rate. RDMA writes of 64 KiB
|
||
// reach 90–97% of the signalling rate on a healthy link; well under that is
|
||
// a degraded cable, a card in a narrow PCIe slot, or traffic crossing a
|
||
// socket.
|
||
func grade(gbps float64, rate int) (string, string) {
|
||
if gbps <= 0 {
|
||
return "fail", "no traffic got through"
|
||
}
|
||
if rate <= 0 {
|
||
return "ok", "the link's rate is not known to compare with"
|
||
}
|
||
pct := gbps / float64(rate) * 100
|
||
switch {
|
||
case pct >= 80:
|
||
return "ok", fmt.Sprintf("%.0f%% of %d Gb/s", pct, rate)
|
||
case pct >= 50:
|
||
return "slow", fmt.Sprintf("%.0f%% of %d Gb/s: check the cable, and the card's PCIe width and NUMA node", pct, rate)
|
||
default:
|
||
return "fail", fmt.Sprintf("%.0f%% of %d Gb/s", pct, rate)
|
||
}
|
||
}
|
||
|
||
func minPositive(a, b int) int {
|
||
switch {
|
||
case a <= 0:
|
||
return b
|
||
case b <= 0:
|
||
return a
|
||
}
|
||
return min(a, b)
|
||
}
|
||
|
||
func orNone(s []string) string {
|
||
if len(s) == 0 {
|
||
return "none"
|
||
}
|
||
return strings.Join(slices.Clone(s), ", ")
|
||
}
|