9db651c75a
Flags GPUs that reach one or more peers only via a SYS-class PCIe hop (crossing the CPU/NUMA-node boundary) in nvidia-smi topo -m. On servers where GPUs are only bridged pairwise via NVLink bridge (no switched NVLink fabric), this is the exact path that traffic between different bridge pairs has to cross, and can cut multi-GPU throughput by 2x+ for workloads spanning more than one pair. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
324 lines
9.5 KiB
Go
324 lines
9.5 KiB
Go
package collector
|
||
|
||
import (
|
||
"bee/audit/internal/schema"
|
||
"fmt"
|
||
"log/slog"
|
||
"os/exec"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
var nv5re = regexp.MustCompile(`(?i)^NV(\d+)$`)
|
||
|
||
// isNVLinkBridgeCandidate returns true for Mellanox PCIe devices that look like
|
||
// NVLink bridge mezzanine cards: narrow link (x2), no host net interfaces.
|
||
// These are the CPU-side PCIe control plane of the NVSwitch fabric on HGX/DGX systems.
|
||
func isNVLinkBridgeCandidate(bdf string, dev schema.HardwarePCIeDevice) bool {
|
||
if !isMellanoxDevice(dev) {
|
||
return false
|
||
}
|
||
if dev.LinkWidth == nil || *dev.LinkWidth > 2 {
|
||
return false
|
||
}
|
||
if len(netIfacesByBDF(bdf)) > 0 {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// confirmNVLinkBridgeDeviceName checks if the lspci DeviceName for bdf contains
|
||
// "NVLINK". This is a targeted single-device call, only executed for candidates
|
||
// already pre-filtered by isNVLinkBridgeCandidate.
|
||
func confirmNVLinkBridgeDeviceName(bdf string) bool {
|
||
out, err := exec.Command("lspci", "-s", bdf, "-v").Output()
|
||
if err != nil {
|
||
return false
|
||
}
|
||
for _, line := range strings.Split(string(out), "\n") {
|
||
if strings.Contains(strings.ToUpper(strings.TrimSpace(line)), "NVLINK") {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// markNVLinkBridge overwrites device_class and adds telemetry flags on a detected
|
||
// NVLink bridge card. Must be called before applyPCIeLinkSpeedWarning so that the
|
||
// correct severity (Critical) is applied.
|
||
func markNVLinkBridge(dev *schema.HardwarePCIeDevice) {
|
||
class := "NVLinkBridge"
|
||
dev.DeviceClass = &class
|
||
if dev.Telemetry == nil {
|
||
dev.Telemetry = map[string]any{}
|
||
}
|
||
dev.Telemetry["nvlink_bridge"] = true
|
||
}
|
||
|
||
// enrichNVLinkBridgesWithGPUTopo cross-references NVLink bridge PCIe status with
|
||
// the GPU-side NVLink topology reported by nvidia-smi. For each bridge device it
|
||
// adds nvlink_topo_all_active and nvlink_topo_min_links to the telemetry, and
|
||
// upgrades a degraded-link Warning to Critical when the fabric is also affected.
|
||
func enrichNVLinkBridgesWithGPUTopo(devs []schema.HardwarePCIeDevice) []schema.HardwarePCIeDevice {
|
||
hasBridge := false
|
||
for _, d := range devs {
|
||
if d.DeviceClass != nil && *d.DeviceClass == "NVLinkBridge" {
|
||
hasBridge = true
|
||
break
|
||
}
|
||
}
|
||
if !hasBridge {
|
||
return devs
|
||
}
|
||
|
||
topo, err := queryNVIDIANVLinkTopo()
|
||
if err != nil {
|
||
slog.Info("nvlink-bridge: nvidia-smi topo unavailable, skipping cross-reference", "err", err)
|
||
return devs
|
||
}
|
||
|
||
for i := range devs {
|
||
if devs[i].DeviceClass == nil || *devs[i].DeviceClass != "NVLinkBridge" {
|
||
continue
|
||
}
|
||
if devs[i].Telemetry == nil {
|
||
devs[i].Telemetry = map[string]any{}
|
||
}
|
||
devs[i].Telemetry["nvlink_topo_all_active"] = topo.AllActive
|
||
devs[i].Telemetry["nvlink_topo_min_links"] = topo.MinNVLinks
|
||
devs[i].Telemetry["nvlink_topo_gpu_count"] = topo.GPUCount
|
||
|
||
// If the bridge PCIe is already degraded AND the fabric is also degraded
|
||
// (missing NVLink connections), escalate to Critical.
|
||
if devs[i].Status != nil && *devs[i].Status == statusCritical && !topo.AllActive {
|
||
devs[i].Telemetry["nvlink_fabric_affected"] = true
|
||
}
|
||
}
|
||
|
||
slog.Info("nvlink-bridge: topo cross-reference applied",
|
||
"gpu_count", topo.GPUCount,
|
||
"all_active", topo.AllActive,
|
||
"min_links", topo.MinNVLinks,
|
||
)
|
||
return devs
|
||
}
|
||
|
||
// nvlinkTopoResult summarises the GPU NVLink connectivity matrix.
|
||
type nvlinkTopoResult struct {
|
||
GPUCount int
|
||
AllActive bool // true if every GPU pair has at least one NVLink bond
|
||
MinNVLinks int // minimum NVLink bonds seen across any GPU pair (0 = some pair disconnected)
|
||
}
|
||
|
||
// queryNVIDIANVLinkTopo runs nvidia-smi topo -m and parses the NVLink matrix.
|
||
func queryNVIDIANVLinkTopo() (nvlinkTopoResult, error) {
|
||
out, err := exec.Command("nvidia-smi", "topo", "-m").Output()
|
||
if err != nil {
|
||
return nvlinkTopoResult{}, err
|
||
}
|
||
return parseNVIDIATopologyMatrix(string(out)), nil
|
||
}
|
||
|
||
// locateGPUTopologyColumns finds the header line of a nvidia-smi topo -m
|
||
// matrix and the 0-based field indices (excluding the row label) that
|
||
// correspond to GPU columns. Returns headerIdx=-1 if fewer than 2 GPU columns
|
||
// are found.
|
||
func locateGPUTopologyColumns(lines []string) (headerIdx int, gpuColIndices []int, gpuCount int) {
|
||
headerIdx = -1
|
||
for i, line := range lines {
|
||
trimmed := strings.TrimSpace(line)
|
||
if strings.HasPrefix(trimmed, "GPU0") {
|
||
parts := strings.Fields(trimmed)
|
||
for j, col := range parts {
|
||
if strings.HasPrefix(col, "GPU") {
|
||
gpuColIndices = append(gpuColIndices, j)
|
||
}
|
||
}
|
||
gpuCount = len(gpuColIndices)
|
||
if gpuCount >= 2 {
|
||
headerIdx = i
|
||
}
|
||
break
|
||
}
|
||
}
|
||
if headerIdx < 0 {
|
||
gpuColIndices = nil
|
||
gpuCount = 0
|
||
}
|
||
return headerIdx, gpuColIndices, gpuCount
|
||
}
|
||
|
||
// parseNVIDIATopologyMatrix extracts the minimum NVLink bond count from the
|
||
// nvidia-smi topo -m matrix.
|
||
//
|
||
// Format (abbreviated):
|
||
//
|
||
// GPU0 GPU1 ... NIC0 NIC1
|
||
// GPU0 X NV18 ... NODE NODE
|
||
// GPU1 NV18 X ... NODE NODE
|
||
// NIC0 NODE NODE... X PIX
|
||
//
|
||
// The header row starts with "GPU0"; its columns may include non-GPU entries
|
||
// (NIC, CPU) which are ignored. Only GPU×GPU cells containing NV# values are
|
||
// counted. X is self; non-NV tokens (NODE, SYS, PHB, PIX) are skipped.
|
||
func parseNVIDIATopologyMatrix(raw string) nvlinkTopoResult {
|
||
lines := strings.Split(raw, "\n")
|
||
headerIdx, gpuColIndices, gpuCount := locateGPUTopologyColumns(lines)
|
||
if headerIdx < 0 {
|
||
return nvlinkTopoResult{}
|
||
}
|
||
|
||
minLinks := -1 // -1 = no NV pair seen yet
|
||
allActive := true
|
||
|
||
for _, line := range lines[headerIdx+1:] {
|
||
trimmed := strings.TrimSpace(line)
|
||
if !strings.HasPrefix(trimmed, "GPU") {
|
||
continue
|
||
}
|
||
cells := strings.Fields(trimmed)
|
||
// cells[0] is the row label (e.g. "GPU0"); cells[1..] are column values.
|
||
// gpuColIndices are 0-based within the header fields, so they map to
|
||
// cells[idx+1] in the data rows (shift by 1 for the row label).
|
||
for _, colIdx := range gpuColIndices {
|
||
dataIdx := colIdx + 1
|
||
if dataIdx >= len(cells) {
|
||
continue
|
||
}
|
||
cell := cells[dataIdx]
|
||
m := nv5re.FindStringSubmatch(cell)
|
||
if len(m) != 2 {
|
||
continue
|
||
}
|
||
n, err := strconv.Atoi(m[1])
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if n == 0 {
|
||
allActive = false
|
||
}
|
||
if minLinks < 0 || n < minLinks {
|
||
minLinks = n
|
||
}
|
||
}
|
||
}
|
||
|
||
if minLinks < 0 {
|
||
minLinks = 0
|
||
}
|
||
|
||
return nvlinkTopoResult{
|
||
GPUCount: gpuCount,
|
||
AllActive: allActive && minLinks > 0,
|
||
MinNVLinks: minLinks,
|
||
}
|
||
}
|
||
|
||
// parseCrossNUMAPeers scans a nvidia-smi topo -m matrix for GPU pairs whose
|
||
// only path is "SYS" — traversing PCIe as well as the SMP interconnect
|
||
// between NUMA nodes (e.g. QPI/UPI). This is the slowest possible GPU-GPU
|
||
// path and, on servers where GPUs are only bridged pairwise via NVLink
|
||
// bridge (no switched NVLink fabric), it is exactly the hop that traffic
|
||
// between different bridge pairs has to cross. Returns a map from GPU index
|
||
// to the peer GPU indices reachable only via this cross-NUMA path.
|
||
func parseCrossNUMAPeers(raw string) map[int][]int {
|
||
lines := strings.Split(raw, "\n")
|
||
headerIdx, gpuColIndices, _ := locateGPUTopologyColumns(lines)
|
||
if headerIdx < 0 {
|
||
return nil
|
||
}
|
||
|
||
// colIdx (0-based within header fields) -> GPU index, in header order.
|
||
colIdxToGPU := make(map[int]int, len(gpuColIndices))
|
||
for gpuIdx, colIdx := range gpuColIndices {
|
||
colIdxToGPU[colIdx] = gpuIdx
|
||
}
|
||
|
||
peers := make(map[int][]int)
|
||
rowGPU := -1
|
||
for _, line := range lines[headerIdx+1:] {
|
||
trimmed := strings.TrimSpace(line)
|
||
if !strings.HasPrefix(trimmed, "GPU") {
|
||
continue
|
||
}
|
||
rowGPU++
|
||
cells := strings.Fields(trimmed)
|
||
for _, colIdx := range gpuColIndices {
|
||
dataIdx := colIdx + 1
|
||
if dataIdx >= len(cells) {
|
||
continue
|
||
}
|
||
colGPU := colIdxToGPU[colIdx]
|
||
if colGPU == rowGPU {
|
||
continue
|
||
}
|
||
if strings.EqualFold(cells[dataIdx], "SYS") {
|
||
peers[rowGPU] = append(peers[rowGPU], colGPU)
|
||
}
|
||
}
|
||
}
|
||
if len(peers) == 0 {
|
||
return nil
|
||
}
|
||
return peers
|
||
}
|
||
|
||
// enrichGPUCrossNUMATopology flags GPUs that reach one or more peer GPUs only
|
||
// via a cross-NUMA-node PCIe hop ("SYS" in nvidia-smi topo -m). Unlike
|
||
// enrichNVLinkBridgesWithGPUTopo, this does not require an NVLink bridge PCIe
|
||
// device to be present: it applies to any multi-GPU box, since the weak point
|
||
// it detects is the path *between* NVLink-bridged pairs (or between GPUs with
|
||
// no NVLink at all), not the bridge itself.
|
||
func enrichGPUCrossNUMATopology(devs []schema.HardwarePCIeDevice) []schema.HardwarePCIeDevice {
|
||
gpuByBDF, err := queryNVIDIAGPUs()
|
||
if err != nil || len(gpuByBDF) < 2 {
|
||
return devs
|
||
}
|
||
|
||
out, err := exec.Command("nvidia-smi", "topo", "-m").Output()
|
||
if err != nil {
|
||
slog.Info("gpu-topology: nvidia-smi topo unavailable, skipping cross-NUMA check", "err", err)
|
||
return devs
|
||
}
|
||
peers := parseCrossNUMAPeers(string(out))
|
||
if len(peers) == 0 {
|
||
return devs
|
||
}
|
||
|
||
bdfToIndex := make(map[string]int, len(gpuByBDF))
|
||
for bdf, info := range gpuByBDF {
|
||
bdfToIndex[bdf] = info.Index
|
||
}
|
||
|
||
for i := range devs {
|
||
if devs[i].BDF == nil {
|
||
continue
|
||
}
|
||
idx, ok := bdfToIndex[normalizePCIeBDF(*devs[i].BDF)]
|
||
if !ok {
|
||
continue
|
||
}
|
||
peerList, ok := peers[idx]
|
||
if !ok {
|
||
continue
|
||
}
|
||
if devs[i].Telemetry == nil {
|
||
devs[i].Telemetry = map[string]any{}
|
||
}
|
||
devs[i].Telemetry["nvlink_cross_numa_peers"] = peerList
|
||
if devs[i].Status == nil || *devs[i].Status == statusOK {
|
||
warn := statusWarning
|
||
devs[i].Status = &warn
|
||
}
|
||
if devs[i].ErrorDescription == nil {
|
||
devs[i].ErrorDescription = stringPtr(fmt.Sprintf(
|
||
"GPU %d reaches GPU(s) %v only via a cross-NUMA-node PCIe path (SYS) — expect reduced bandwidth/increased latency for tensor-parallel workloads spanning these GPUs",
|
||
idx, peerList))
|
||
}
|
||
}
|
||
|
||
slog.Info("gpu-topology: cross-NUMA peers detected", "affected_gpus", len(peers))
|
||
return devs
|
||
}
|