Files
llmgw/internal/control/fabrictest/fabrictest.go
T
Ilker ManapandClaude Opus 5.5 7bb360fc78 Measure two nodes' fabric rail by rail: llmgw ctl fabric-test
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>
2026-09-29 21:29:23 +02:00

236 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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), ", ")
}