fix(pcie): verify GPU links under real bandwidth load
This commit is contained in:
@@ -194,7 +194,7 @@ func TestRunNvidiaBandwidthPackSplitsPerSocketThenAll(t *testing.T) {
|
||||
"03-dcgmi-nvbandwidth-socket0.log",
|
||||
"04-dcgmi-nvbandwidth-socket1.log",
|
||||
"05-dcgmi-nvbandwidth-all.log",
|
||||
"06-nvidia-smi-after.log",
|
||||
"nvidia-pcie-link-under-load-report.txt",
|
||||
}
|
||||
for _, name := range wantFiles {
|
||||
if _, err := os.Stat(filepath.Join(runDir, name)); err != nil {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bee/audit/internal/collector"
|
||||
)
|
||||
|
||||
// nvidiaPCIeBandwidthFinding is one GPU's post-load PCIe link-speed result.
|
||||
@@ -21,38 +21,18 @@ type nvidiaPCIeBandwidthFinding struct {
|
||||
Width int
|
||||
MaxWidth int
|
||||
Degraded bool
|
||||
Supported bool
|
||||
Sampled bool
|
||||
}
|
||||
|
||||
// RunNvidiaPCIeBandwidthPack drives real host<->device traffic across the
|
||||
// GPUs' PCIe links (via `dcgmi diag -r nvbandwidth`, the same tool the
|
||||
// existing nvidia-bandwidth SAT uses for GPU-to-GPU throughput) and then
|
||||
// resamples each GPU's PCIe link speed from sysfs immediately afterward.
|
||||
//
|
||||
// This is deliberately a separate, narrower check from "nvidia-bandwidth":
|
||||
// that SAT's overall_status reflects nvbandwidth's own pass/fail (did the
|
||||
// measured GB/s clear its internal threshold), never PCIe link speed
|
||||
// itself. This pack exists purely to answer "did sustained real traffic
|
||||
// make the link train up to its negotiated maximum" — the load-bearing
|
||||
// counterpart to the idle-time collector reading that used to (falsely)
|
||||
// drive pcie:gpu:nvidia's status. See
|
||||
// bible-local/decisions/2026-08-24-pcie-gpu-gen1-idle-warning-unresolved.md.
|
||||
func (s *System) RunNvidiaPCIeBandwidthPack(ctx context.Context, baseDir string, gpuIndices []int, logFunc func(string)) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if baseDir == "" {
|
||||
baseDir = "/var/log/bee-sat"
|
||||
}
|
||||
ts := time.Now().UTC().Format("20060102-150405")
|
||||
runDir := filepath.Join(baseDir, "nvidia-pcie-bandwidth-"+ts)
|
||||
if err := os.MkdirAll(runDir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
verboseLog := filepath.Join(runDir, "verbose.log")
|
||||
|
||||
// captureNvidiaPCIeLinkBaseline records link capabilities immediately before
|
||||
// the existing nvbandwidth SAT. The post-load half is completed by
|
||||
// finishNvidiaPCIeLinkCheck as soon as that same SAT returns, so one real PCIe
|
||||
// traffic pass provides both the DCGM verdict and the negotiated-link verdict.
|
||||
func captureNvidiaPCIeLinkBaseline(gpuIndices []int) ([]nvidiaPCIeBandwidthFinding, error) {
|
||||
bdfByIndex, err := gpuIndexToBDF(gpuIndices)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve GPU BDFs: %w", err)
|
||||
return nil, fmt.Errorf("resolve GPU BDFs: %w", err)
|
||||
}
|
||||
indices := make([]int, 0, len(bdfByIndex))
|
||||
for idx := range bdfByIndex {
|
||||
@@ -63,39 +43,37 @@ func (s *System) RunNvidiaPCIeBandwidthPack(ctx context.Context, baseDir string,
|
||||
findings := make([]nvidiaPCIeBandwidthFinding, 0, len(indices))
|
||||
for _, idx := range indices {
|
||||
bdf := bdfByIndex[idx]
|
||||
before, _ := readPCIeSysfsString(bdf, "current_link_speed")
|
||||
max, _ := readPCIeSysfsString(bdf, "max_link_speed")
|
||||
maxWidth, _ := readPCIeSysfsInt(bdf, "max_link_width")
|
||||
before, beforeOK := readPCIeSysfsString(bdf, "current_link_speed")
|
||||
max, maxOK := readPCIeSysfsString(bdf, "max_link_speed")
|
||||
maxWidth, widthOK := readPCIeSysfsInt(bdf, "max_link_width")
|
||||
findings = append(findings, nvidiaPCIeBandwidthFinding{
|
||||
Index: idx, BDF: bdf, BeforeSpeed: before, MaxSpeed: max, MaxWidth: maxWidth,
|
||||
Supported: beforeOK && maxOK && widthOK,
|
||||
})
|
||||
}
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
cmd := []string{"dcgmi", "diag", "-r", "nvbandwidth"}
|
||||
if len(indices) > 0 {
|
||||
cmd = append(cmd, "-i", joinIndexList(indices))
|
||||
func finishNvidiaPCIeLinkCheck(runDir string, findings []nvidiaPCIeBandwidthFinding) error {
|
||||
report := renderNvidiaPCIeBandwidthReport(findings)
|
||||
if err := os.WriteFile(filepath.Join(runDir, "nvidia-pcie-link-under-load-report.txt"), []byte(report), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
out, runErr := runSATCommandCtx(ctx, verboseLog, "dcgmi-nvbandwidth", cmd, nil, logFunc)
|
||||
_ = os.WriteFile(filepath.Join(runDir, "01-dcgmi-nvbandwidth.log"), out, 0644)
|
||||
return appendNvidiaPCIeLinkSummary(filepath.Join(runDir, "summary.txt"), findings)
|
||||
}
|
||||
|
||||
func sampleNvidiaPCIeLinkAfterLoad(findings []nvidiaPCIeBandwidthFinding) {
|
||||
for i := range findings {
|
||||
bdf := findings[i].BDF
|
||||
after, _ := readPCIeSysfsString(bdf, "current_link_speed")
|
||||
width, _ := readPCIeSysfsInt(bdf, "current_link_width")
|
||||
after, afterOK := readPCIeSysfsString(bdf, "current_link_speed")
|
||||
width, widthOK := readPCIeSysfsInt(bdf, "current_link_width")
|
||||
findings[i].AfterSpeed = after
|
||||
findings[i].Width = width
|
||||
findings[i].Degraded = after != findings[i].MaxSpeed
|
||||
findings[i].Sampled = true
|
||||
findings[i].Supported = findings[i].Supported && afterOK && widthOK
|
||||
findings[i].Degraded = findings[i].Supported &&
|
||||
(after != findings[i].MaxSpeed || width != findings[i].MaxWidth)
|
||||
}
|
||||
|
||||
summary := renderNvidiaPCIeBandwidthSummary(findings, runErr)
|
||||
if err := os.WriteFile(filepath.Join(runDir, "summary.txt"), []byte(summary), 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
report := renderNvidiaPCIeBandwidthReport(findings, runErr)
|
||||
if err := os.WriteFile(filepath.Join(runDir, "nvidia-pcie-bandwidth-report.txt"), []byte(report), 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return runDir, nil
|
||||
}
|
||||
|
||||
// gpuIndexToBDF resolves each of gpuIndices to its PCI BDF via nvidia-smi.
|
||||
@@ -131,35 +109,79 @@ func gpuIndexToBDF(gpuIndices []int) (map[int]string, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func renderNvidiaPCIeBandwidthSummary(findings []nvidiaPCIeBandwidthFinding, runErr error) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "run_at_utc=%s\n", time.Now().UTC().Format(time.RFC3339))
|
||||
fmt.Fprintf(&b, "gpu_count=%d\n", len(findings))
|
||||
func readPCIeSysfsString(bdf, attr string) (string, bool) {
|
||||
raw, err := satReadFile(filepath.Join("/sys/bus/pci/devices", bdf, attr))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
v := strings.TrimSpace(string(raw))
|
||||
if v == "" {
|
||||
return "", false
|
||||
}
|
||||
return collector.NormalizePCILinkSpeed(v), true
|
||||
}
|
||||
|
||||
func readPCIeSysfsInt(bdf, attr string) (int, bool) {
|
||||
raw, err := satReadFile(filepath.Join("/sys/bus/pci/devices", bdf, attr))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
v, err := strconv.Atoi(strings.TrimSpace(string(raw)))
|
||||
if err != nil || v < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
func appendNvidiaPCIeLinkSummary(path string, findings []nvidiaPCIeBandwidthFinding) error {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var extra strings.Builder
|
||||
fmt.Fprintf(&extra, "pcie_gpu_count=%d\n", len(findings))
|
||||
degraded := 0
|
||||
supported := 0
|
||||
var reasons []string
|
||||
for _, f := range findings {
|
||||
fmt.Fprintf(&b, "gpu%d_status=%s\n", f.Index, statusLabel(!f.Degraded))
|
||||
status := "UNSUPPORTED"
|
||||
if f.Supported && f.Sampled {
|
||||
supported++
|
||||
status = statusLabel(!f.Degraded)
|
||||
}
|
||||
fmt.Fprintf(&extra, "pcie_gpu%d_status=%s\n", f.Index, status)
|
||||
if f.Degraded {
|
||||
degraded++
|
||||
reasons = append(reasons, fmt.Sprintf("GPU%d (%s): under load at %s x%d, capable of %s x%d",
|
||||
f.Index, f.BDF, f.AfterSpeed, f.Width, f.MaxSpeed, f.MaxWidth))
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "degraded=%d\n", degraded)
|
||||
if runErr != nil {
|
||||
fmt.Fprintf(&b, "dcgmi_nvbandwidth_error=%s\n", runErr.Error())
|
||||
}
|
||||
fmt.Fprintf(&extra, "pcie_degraded=%d\n", degraded)
|
||||
linkStatus := "UNSUPPORTED"
|
||||
if degraded > 0 {
|
||||
fmt.Fprintln(&b, "overall_status=FAILED")
|
||||
var reasons []string
|
||||
for _, f := range findings {
|
||||
if f.Degraded {
|
||||
reasons = append(reasons, fmt.Sprintf("GPU%d (%s): still at %s under load, capable of %s",
|
||||
f.Index, f.BDF, f.AfterSpeed, f.MaxSpeed))
|
||||
linkStatus = "FAILED"
|
||||
} else if supported == len(findings) && supported > 0 {
|
||||
linkStatus = "OK"
|
||||
}
|
||||
fmt.Fprintf(&extra, "pcie_link_under_load_status=%s\n", linkStatus)
|
||||
if len(reasons) > 0 {
|
||||
fmt.Fprintf(&extra, "pcie_link_under_load_detail=%s\n", strings.Join(reasons, "; "))
|
||||
}
|
||||
if linkStatus == "FAILED" {
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
for i := range lines {
|
||||
if strings.HasPrefix(lines[i], "overall_status=") {
|
||||
lines[i] = "overall_status=FAILED"
|
||||
break
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "warnings=%s\n", strings.Join(reasons, "; "))
|
||||
} else {
|
||||
fmt.Fprintln(&b, "overall_status=OK")
|
||||
raw = []byte(strings.Join(lines, "\n"))
|
||||
}
|
||||
return b.String()
|
||||
if len(raw) > 0 && raw[len(raw)-1] != '\n' {
|
||||
raw = append(raw, '\n')
|
||||
}
|
||||
raw = append(raw, extra.String()...)
|
||||
return os.WriteFile(path, raw, 0644)
|
||||
}
|
||||
|
||||
func statusLabel(ok bool) string {
|
||||
@@ -169,18 +191,17 @@ func statusLabel(ok bool) string {
|
||||
return "FAILED"
|
||||
}
|
||||
|
||||
func renderNvidiaPCIeBandwidthReport(findings []nvidiaPCIeBandwidthFinding, runErr error) string {
|
||||
func renderNvidiaPCIeBandwidthReport(findings []nvidiaPCIeBandwidthFinding) string {
|
||||
var b strings.Builder
|
||||
line := strings.Repeat("=", 80)
|
||||
b.WriteString(line + "\n")
|
||||
b.WriteString("NVIDIA GPU PCIe Bandwidth / Link-Under-Load Check\n")
|
||||
b.WriteString("NVIDIA GPU PCIe Link-Under-Load Check\n")
|
||||
b.WriteString(line + "\n\n")
|
||||
if runErr != nil {
|
||||
fmt.Fprintf(&b, "dcgmi diag -r nvbandwidth: %s\n\n", runErr.Error())
|
||||
}
|
||||
for _, f := range findings {
|
||||
verdict := "OK"
|
||||
if f.Degraded {
|
||||
verdict := "UNSUPPORTED"
|
||||
if f.Supported && f.Sampled && !f.Degraded {
|
||||
verdict = "OK"
|
||||
} else if f.Degraded {
|
||||
verdict = "DEGRADED"
|
||||
}
|
||||
fmt.Fprintf(&b, "GPU%d (%s): %s\n", f.Index, f.BDF, verdict)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPCIeLinkSummaryAcceptsIdleGen1WhenLinkReachesMaximumUnderLoad(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "summary.txt")
|
||||
if err := os.WriteFile(path, []byte("overall_status=OK\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
findings := []nvidiaPCIeBandwidthFinding{{
|
||||
Index: 0, BDF: "0000:05:00.0", BeforeSpeed: "Gen1", AfterSpeed: "Gen5",
|
||||
MaxSpeed: "Gen5", Width: 16, MaxWidth: 16, Supported: true, Sampled: true,
|
||||
}}
|
||||
|
||||
if err := appendNvidiaPCIeLinkSummary(path, findings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(raw)
|
||||
if !strings.Contains(got, "overall_status=OK\n") || !strings.Contains(got, "pcie_link_under_load_status=OK\n") {
|
||||
t.Fatalf("summary did not accept a link that rose from idle Gen1 to maximum under load:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPCIeLinkSummaryFailsLinkStillBelowMaximumAfterLoad(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "summary.txt")
|
||||
if err := os.WriteFile(path, []byte("overall_status=UNSUPPORTED\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
findings := []nvidiaPCIeBandwidthFinding{{
|
||||
Index: 0, BDF: "0000:05:00.0", BeforeSpeed: "Gen1", AfterSpeed: "Gen1",
|
||||
MaxSpeed: "Gen5", Width: 16, MaxWidth: 16, Supported: true, Sampled: true, Degraded: true,
|
||||
}}
|
||||
|
||||
if err := appendNvidiaPCIeLinkSummary(path, findings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(raw)
|
||||
if !strings.Contains(got, "overall_status=FAILED\n") || !strings.Contains(got, "pcie_link_under_load_status=FAILED\n") {
|
||||
t.Fatalf("summary did not fail a link that stayed degraded under load:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPCIeLinkSummaryIsUnsupportedWhenLoadNeverRan(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "summary.txt")
|
||||
if err := os.WriteFile(path, []byte("overall_status=FAILED\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
findings := []nvidiaPCIeBandwidthFinding{{
|
||||
Index: 0, BDF: "0000:05:00.0", BeforeSpeed: "Gen1", MaxSpeed: "Gen5",
|
||||
MaxWidth: 16, Supported: true,
|
||||
}}
|
||||
|
||||
if err := appendNvidiaPCIeLinkSummary(path, findings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(raw)
|
||||
if !strings.Contains(got, "pcie_link_under_load_status=UNSUPPORTED\n") {
|
||||
t.Fatalf("summary claimed a link verdict although the load callback never ran:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -1,506 +0,0 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bee/audit/internal/collector"
|
||||
)
|
||||
|
||||
// pcieLinkRetrainTimeout bounds how long we wait for a device to finish
|
||||
// retraining (clear the Link Status "Link Training" bit) before giving up
|
||||
// and reading whatever speed it settled on anyway. The PCIe spec allows up
|
||||
// to 100ms for Gen1-3 and longer for higher generations with equalization;
|
||||
// this is generous headroom above that.
|
||||
const pcieLinkRetrainTimeout = 2 * time.Second
|
||||
|
||||
// pcieLinkFinding is one device's before/after link-speed retrain result.
|
||||
type pcieLinkFinding struct {
|
||||
BDF string
|
||||
Description string
|
||||
VendorID string
|
||||
ClassCode string
|
||||
IsGPU bool
|
||||
GPUVendor string // "nvidia" or "amd", only set when IsGPU
|
||||
Skipped string // non-empty reason this device wasn't retrained
|
||||
BeforeSpeed string
|
||||
AfterSpeed string
|
||||
MaxSpeed string
|
||||
PortMaxSpeed string // bridge's own capability when MaxSpeed is limited by its downstream peer
|
||||
Width int
|
||||
MaxWidth int
|
||||
PortMaxWidth int // bridge's own capability when MaxWidth is limited by its downstream peer
|
||||
Degraded bool
|
||||
NotPresent bool // true when the slot trained to zero lanes: nothing is plugged in (or it fell off the bus), not a speed regression
|
||||
}
|
||||
|
||||
// RunPCIeLinkCheckPack forces every enabled PCIe device to retrain its link
|
||||
// (via the PCIe Link Control register's spec-defined Retrain Link bit — see
|
||||
// PCIe base spec, Link Control Register, bit 5) and compares the
|
||||
// post-retrain negotiated speed against the device's own reported maximum.
|
||||
//
|
||||
// This exists because a plain idle-time sysfs read of current_link_speed is
|
||||
// not a reliable fault signal: NVIDIA GPUs (and other devices with runtime
|
||||
// power management) deliberately downclock their PCIe link to save power
|
||||
// while idle, which is indistinguishable from a real degraded slot/riser/
|
||||
// cable without either sustained traffic or a forced retrain. Forcing a
|
||||
// retrain sidesteps needing a device-specific load generator (bee-gpu-burn
|
||||
// exists for GPUs; nothing plays that role for NICs, HBAs, or PCIe
|
||||
// switches) — retraining is a PCIe-spec mechanism every endpoint supports,
|
||||
// so this one check covers every PCIe device in the machine, not just
|
||||
// GPUs. See bible-local/decisions/2026-08-24-pcie-gpu-gen1-idle-warning-unresolved.md
|
||||
// for the history of narrower attempts that didn't generalize.
|
||||
//
|
||||
// Disabled devices (sysfs enable==0 — e.g. PCIe fabric-management endpoints
|
||||
// the kernel never activates, per the 2026-06-12 decision) are left alone:
|
||||
// they carry no data traffic, so there is nothing to verify and no reason
|
||||
// to poke them.
|
||||
func (s *System) RunPCIeLinkCheckPack(ctx context.Context, baseDir string, logFunc func(string)) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if baseDir == "" {
|
||||
baseDir = "/var/log/bee-sat"
|
||||
}
|
||||
ts := time.Now().UTC().Format("20060102-150405")
|
||||
runDir := filepath.Join(baseDir, "pcie-link-"+ts)
|
||||
if err := os.MkdirAll(runDir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
verboseLog := filepath.Join(runDir, "verbose.log")
|
||||
|
||||
bdfs, err := listPCIDeviceBDFs()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list PCI devices: %w", err)
|
||||
}
|
||||
|
||||
var findings []pcieLinkFinding
|
||||
for _, bdf := range bdfs {
|
||||
if logFunc != nil {
|
||||
logFunc(fmt.Sprintf("=== %s ===", bdf))
|
||||
}
|
||||
f := retrainAndSamplePCIeDevice(ctx, verboseLog, bdf, logFunc)
|
||||
findings = append(findings, f)
|
||||
}
|
||||
|
||||
summary := renderPCIeLinkCheckSummary(findings)
|
||||
if err := os.WriteFile(filepath.Join(runDir, "summary.txt"), []byte(summary), 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
report := renderPCIeLinkCheckReport(findings)
|
||||
if err := os.WriteFile(filepath.Join(runDir, "pcie-link-report.txt"), []byte(report), 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return runDir, nil
|
||||
}
|
||||
|
||||
// listPCIDeviceBDFs returns every BDF under /sys/bus/pci/devices, sorted for
|
||||
// deterministic report ordering.
|
||||
func listPCIDeviceBDFs() ([]string, error) {
|
||||
entries, err := os.ReadDir("/sys/bus/pci/devices")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bdfs := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
bdfs = append(bdfs, e.Name())
|
||||
}
|
||||
sort.Strings(bdfs)
|
||||
return bdfs, nil
|
||||
}
|
||||
|
||||
func retrainAndSamplePCIeDevice(ctx context.Context, verboseLog, bdf string, logFunc func(string)) pcieLinkFinding {
|
||||
f := pcieLinkFinding{BDF: bdf}
|
||||
|
||||
vendor, _ := readPCIeSysfsHex(bdf, "vendor")
|
||||
class, _ := readPCIeSysfsHex(bdf, "class")
|
||||
f.VendorID = vendor
|
||||
f.ClassCode = class
|
||||
f.IsGPU, f.GPUVendor = classifyGPUFromVendorClass(vendor, class)
|
||||
f.Description = pcieDeviceDescription(ctx, verboseLog, bdf, logFunc)
|
||||
|
||||
if enabled, ok := readPCIeSysfsInt(bdf, "enable"); ok && enabled == 0 {
|
||||
f.Skipped = "device disabled (no data traffic; link state has no operational impact)"
|
||||
return f
|
||||
}
|
||||
|
||||
before, beforeOK := readPCIeSysfsString(bdf, "current_link_speed")
|
||||
maxSpeed, maxOK := readPCIeSysfsString(bdf, "max_link_speed")
|
||||
width, _ := readPCIeSysfsInt(bdf, "current_link_width")
|
||||
maxWidth, _ := readPCIeSysfsInt(bdf, "max_link_width")
|
||||
f.BeforeSpeed = before
|
||||
f.MaxSpeed = maxSpeed
|
||||
f.MaxWidth = maxWidth
|
||||
if !beforeOK || !maxOK {
|
||||
f.Skipped = "no PCIe link-speed attributes in sysfs (not a link-trained endpoint)"
|
||||
return f
|
||||
}
|
||||
|
||||
if width == 0 {
|
||||
// A downstream switch/root port with nothing seated reads zero
|
||||
// trained lanes even before we touch it. Plenty of legitimate
|
||||
// configs leave slots like this unpopulated (not every server ships
|
||||
// every NIC/riser slot filled), so this is not by itself evidence
|
||||
// of anything wrong — retraining an empty slot can't produce a
|
||||
// meaningful speed reading, and there's no baseline here to say
|
||||
// "this used to have a card." Skip it exactly like a disabled
|
||||
// device: nothing to verify, no reason to fail the run over it.
|
||||
f.NotPresent = true
|
||||
f.Skipped = "no device present downstream (empty slot/riser — nothing to retrain)"
|
||||
return f
|
||||
}
|
||||
|
||||
// A bridge/root port reports its own maximum capability in sysfs, not
|
||||
// the maximum mutually supported by the device at the other end of the
|
||||
// link. Comparing a Gen4 x16 root port directly with a Gen3 x8 or Gen2
|
||||
// x4 endpoint therefore produces a false degradation even though the
|
||||
// link is running at the fastest rate the endpoint supports. The child
|
||||
// device is tested separately, so use its advertised capability to
|
||||
// calculate the real target for this bridge-side view of the same link.
|
||||
if isPCIeBridgeClass(class) {
|
||||
if peerSpeed, peerWidth, ok := downstreamPCIeLinkCapability(bdf); ok {
|
||||
f.PortMaxSpeed = f.MaxSpeed
|
||||
f.PortMaxWidth = f.MaxWidth
|
||||
f.MaxSpeed = minPCIeLinkSpeed(f.MaxSpeed, peerSpeed)
|
||||
f.MaxWidth = minPositiveInt(f.MaxWidth, peerWidth)
|
||||
// The degradation verdict below compares against maxSpeed, so
|
||||
// it has to track the peer-capped target too — otherwise a
|
||||
// bridge whose port out-specs its downstream device (e.g. a
|
||||
// Gen5 root port feeding a Gen4 HBA) is flagged DEGRADED even
|
||||
// though the link is at the fastest rate the pair supports.
|
||||
maxSpeed = f.MaxSpeed
|
||||
}
|
||||
}
|
||||
|
||||
if err := retrainPCIeLink(ctx, verboseLog, bdf, logFunc); err != nil {
|
||||
f.Skipped = "retrain failed: " + err.Error()
|
||||
f.AfterSpeed = before
|
||||
f.Width = width
|
||||
// before/maxSpeed are already normalized "GenN" labels (see
|
||||
// readPCIeSysfsString) — compare directly, don't re-normalize.
|
||||
f.Degraded = before != maxSpeed
|
||||
return f
|
||||
}
|
||||
|
||||
after, _ := readPCIeSysfsString(bdf, "current_link_speed")
|
||||
widthAfter, _ := readPCIeSysfsInt(bdf, "current_link_width")
|
||||
if widthAfter == 0 {
|
||||
// The device answered before the retrain but is gone immediately
|
||||
// after it (fell off the bus mid-check) — unlike the pre-retrain
|
||||
// case above, this had a live link a moment ago, so it's worth
|
||||
// surfacing rather than silently skipping.
|
||||
f.AfterSpeed = after
|
||||
f.Width = widthAfter
|
||||
f.NotPresent = true
|
||||
f.Degraded = true
|
||||
return f
|
||||
}
|
||||
f.AfterSpeed = after
|
||||
f.Width = widthAfter
|
||||
f.Degraded = after != maxSpeed
|
||||
return f
|
||||
}
|
||||
|
||||
// retrainPCIeLink sets the Retrain Link bit (bit 5) of the PCI Express
|
||||
// Capability's Link Control register via setpci, then polls the Link
|
||||
// Status register's Link Training bit (bit 11) until it clears or
|
||||
// pcieLinkRetrainTimeout elapses.
|
||||
func retrainPCIeLink(ctx context.Context, verboseLog, bdf string, logFunc func(string)) error {
|
||||
linkCtrlOut, err := runSATCommandCtx(ctx, verboseLog, "setpci-read-"+bdf,
|
||||
[]string{"setpci", "-s", bdf, "CAP_EXP+0x10.w"}, nil, logFunc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read Link Control: %w", err)
|
||||
}
|
||||
cur, err := strconv.ParseUint(strings.TrimSpace(string(linkCtrlOut)), 16, 16)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse Link Control %q: %w", linkCtrlOut, err)
|
||||
}
|
||||
const retrainLinkBit = 0x0020
|
||||
newVal := uint16(cur) | retrainLinkBit
|
||||
|
||||
if _, err := runSATCommandCtx(ctx, verboseLog, "setpci-retrain-"+bdf,
|
||||
[]string{"setpci", "-s", bdf, fmt.Sprintf("CAP_EXP+0x10.w=%04x", newVal)}, nil, logFunc); err != nil {
|
||||
return fmt.Errorf("write Retrain Link bit: %w", err)
|
||||
}
|
||||
|
||||
const linkTrainingBit = 0x0800
|
||||
deadline := time.Now().Add(pcieLinkRetrainTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
statusOut, err := runSATCommandCtx(ctx, verboseLog, "setpci-status-"+bdf,
|
||||
[]string{"setpci", "-s", bdf, "CAP_EXP+0x12.w"}, nil, nil)
|
||||
if err == nil {
|
||||
if status, perr := strconv.ParseUint(strings.TrimSpace(string(statusOut)), 16, 16); perr == nil {
|
||||
if status&linkTrainingBit == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
// Timed out waiting for training to clear; the caller still samples
|
||||
// whatever speed sysfs reports, which is the honest answer either way.
|
||||
return nil
|
||||
}
|
||||
|
||||
func pcieDeviceDescription(ctx context.Context, verboseLog, bdf string, logFunc func(string)) string {
|
||||
out, err := runSATCommandCtx(ctx, verboseLog, "lspci-"+bdf, []string{"lspci", "-s", bdf}, nil, logFunc)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
line := strings.TrimSpace(string(out))
|
||||
if idx := strings.Index(line, "\n"); idx >= 0 {
|
||||
line = line[:idx]
|
||||
}
|
||||
if idx := strings.Index(line, " "); idx >= 0 {
|
||||
return strings.TrimSpace(line[idx+1:])
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func readPCIeSysfsString(bdf, attr string) (string, bool) {
|
||||
raw, err := os.ReadFile(filepath.Join("/sys/bus/pci/devices", bdf, attr))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
v := strings.TrimSpace(string(raw))
|
||||
if v == "" {
|
||||
return "", false
|
||||
}
|
||||
return collector.NormalizePCILinkSpeed(v), true
|
||||
}
|
||||
|
||||
func readPCIeSysfsInt(bdf, attr string) (int, bool) {
|
||||
raw, err := os.ReadFile(filepath.Join("/sys/bus/pci/devices", bdf, attr))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
v, err := strconv.Atoi(strings.TrimSpace(string(raw)))
|
||||
if err != nil || v < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
func readPCIeSysfsHex(bdf, attr string) (string, bool) {
|
||||
raw, err := os.ReadFile(filepath.Join("/sys/bus/pci/devices", bdf, attr))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(string(raw)), true
|
||||
}
|
||||
|
||||
// isPCIeBridgeClass matches PCI class 0x0604xx (PCI-to-PCI bridge). These
|
||||
// functions describe the upstream side of a downstream link, so their own
|
||||
// max_link_* values must be capped by the peer's capability.
|
||||
func isPCIeBridgeClass(classHex string) bool {
|
||||
c := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(classHex)), "0x")
|
||||
return len(c) >= 4 && c[:4] == "0604"
|
||||
}
|
||||
|
||||
// downstreamPCIeLinkCapability returns the strongest capability advertised
|
||||
// by a bridge's immediate child functions. In sysfs those functions are
|
||||
// direct entries below the bridge device directory. Multifunction devices
|
||||
// expose several children for one physical link; taking the strongest values
|
||||
// avoids understating the link because one auxiliary function omitted data.
|
||||
func downstreamPCIeLinkCapability(bdf string) (speed string, width int, ok bool) {
|
||||
bridgeDir := filepath.Join("/sys/bus/pci/devices", bdf)
|
||||
return downstreamPCIeLinkCapabilityAt(bridgeDir)
|
||||
}
|
||||
|
||||
func downstreamPCIeLinkCapabilityAt(bridgeDir string) (speed string, width int, ok bool) {
|
||||
entries, err := os.ReadDir(bridgeDir)
|
||||
if err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !isFullPCIBDF(entry.Name()) {
|
||||
continue
|
||||
}
|
||||
childDir := filepath.Join(bridgeDir, entry.Name())
|
||||
rawSpeed, speedErr := os.ReadFile(filepath.Join(childDir, "max_link_speed"))
|
||||
if speedErr != nil {
|
||||
continue
|
||||
}
|
||||
childSpeed := collector.NormalizePCILinkSpeed(strings.TrimSpace(string(rawSpeed)))
|
||||
if pcieGeneration(childSpeed) > pcieGeneration(speed) {
|
||||
speed = childSpeed
|
||||
}
|
||||
if rawWidth, widthErr := os.ReadFile(filepath.Join(childDir, "max_link_width")); widthErr == nil {
|
||||
if childWidth, parseErr := strconv.Atoi(strings.TrimSpace(string(rawWidth))); parseErr == nil && childWidth > width {
|
||||
width = childWidth
|
||||
}
|
||||
}
|
||||
ok = true
|
||||
}
|
||||
return speed, width, ok && speed != ""
|
||||
}
|
||||
|
||||
func isFullPCIBDF(s string) bool {
|
||||
if len(s) != len("0000:00:00.0") || s[4] != ':' || s[7] != ':' || s[10] != '.' {
|
||||
return false
|
||||
}
|
||||
for i, r := range s {
|
||||
if i == 4 || i == 7 || i == 10 {
|
||||
continue
|
||||
}
|
||||
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func minPCIeLinkSpeed(a, b string) string {
|
||||
ga, gb := pcieGeneration(a), pcieGeneration(b)
|
||||
switch {
|
||||
case ga == 0:
|
||||
return b
|
||||
case gb == 0 || ga <= gb:
|
||||
return a
|
||||
default:
|
||||
return b
|
||||
}
|
||||
}
|
||||
|
||||
func pcieGeneration(speed string) int {
|
||||
v := strings.TrimPrefix(strings.TrimSpace(speed), "Gen")
|
||||
gen, _ := strconv.Atoi(v)
|
||||
return gen
|
||||
}
|
||||
|
||||
func minPositiveInt(a, b int) int {
|
||||
switch {
|
||||
case a <= 0:
|
||||
return b
|
||||
case b <= 0 || a <= b:
|
||||
return a
|
||||
default:
|
||||
return b
|
||||
}
|
||||
}
|
||||
|
||||
// classifyGPUFromVendorClass reports whether a device is a GPU die itself
|
||||
// (PCI base class 0x03 — Display Controller — under NVIDIA/AMD's vendor
|
||||
// ID), as opposed to a same-vendor companion device (NIC, storage
|
||||
// controller, NVLink bridge) that shares the GPU's PCI vendor ID. Class-code
|
||||
// based, not name-substring based, per the same reasoning as
|
||||
// collector.IsGPUClass.
|
||||
func classifyGPUFromVendorClass(vendorHex, classHex string) (isGPU bool, vendor string) {
|
||||
v := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(vendorHex)), "0x")
|
||||
c := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(classHex)), "0x")
|
||||
if len(c) < 2 || c[:2] != "03" {
|
||||
return false, ""
|
||||
}
|
||||
switch v {
|
||||
case "10de":
|
||||
return true, "nvidia"
|
||||
case "1002":
|
||||
return true, "amd"
|
||||
default:
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
|
||||
func renderPCIeLinkCheckSummary(findings []pcieLinkFinding) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "run_at_utc=%s\n", time.Now().UTC().Format(time.RFC3339))
|
||||
fmt.Fprintf(&b, "devices_tested=%d\n", len(findings))
|
||||
|
||||
gpuStatus := map[string]string{} // vendor -> OK/FAILED
|
||||
otherDegraded := 0
|
||||
otherTested := 0
|
||||
anyDegraded := false
|
||||
for _, f := range findings {
|
||||
if f.Skipped != "" && f.AfterSpeed == "" {
|
||||
continue
|
||||
}
|
||||
if f.IsGPU {
|
||||
if _, ok := gpuStatus[f.GPUVendor]; !ok {
|
||||
gpuStatus[f.GPUVendor] = "OK"
|
||||
}
|
||||
if f.Degraded {
|
||||
gpuStatus[f.GPUVendor] = "FAILED"
|
||||
anyDegraded = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
otherTested++
|
||||
if f.Degraded {
|
||||
otherDegraded++
|
||||
anyDegraded = true
|
||||
}
|
||||
}
|
||||
for _, vendor := range []string{"nvidia", "amd"} {
|
||||
if status, ok := gpuStatus[vendor]; ok {
|
||||
fmt.Fprintf(&b, "gpu_%s_status=%s\n", vendor, status)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "other_devices_tested=%d\n", otherTested)
|
||||
fmt.Fprintf(&b, "other_devices_degraded=%d\n", otherDegraded)
|
||||
if otherTested > 0 {
|
||||
if otherDegraded > 0 {
|
||||
fmt.Fprintln(&b, "other_status=FAILED")
|
||||
} else {
|
||||
fmt.Fprintln(&b, "other_status=OK")
|
||||
}
|
||||
}
|
||||
|
||||
if anyDegraded {
|
||||
fmt.Fprintln(&b, "overall_status=FAILED")
|
||||
var reasons []string
|
||||
for _, f := range findings {
|
||||
if !f.Degraded {
|
||||
continue
|
||||
}
|
||||
if f.NotPresent {
|
||||
reasons = append(reasons, fmt.Sprintf("%s (%s): no device detected downstream (link down / empty slot or riser, capable of %s)",
|
||||
f.BDF, nonEmptyOr(f.Description, "unknown device"), f.MaxSpeed))
|
||||
continue
|
||||
}
|
||||
reasons = append(reasons, fmt.Sprintf("%s (%s): retrained to %s, capable of %s",
|
||||
f.BDF, nonEmptyOr(f.Description, "unknown device"), f.AfterSpeed, f.MaxSpeed))
|
||||
}
|
||||
fmt.Fprintf(&b, "warnings=%s\n", strings.Join(reasons, "; "))
|
||||
} else {
|
||||
fmt.Fprintln(&b, "overall_status=OK")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderPCIeLinkCheckReport(findings []pcieLinkFinding) string {
|
||||
var b strings.Builder
|
||||
line := strings.Repeat("=", 80)
|
||||
b.WriteString(line + "\n")
|
||||
b.WriteString("PCIe Link Retrain Check\n")
|
||||
b.WriteString(line + "\n\n")
|
||||
for _, f := range findings {
|
||||
fmt.Fprintf(&b, "%s %s\n", f.BDF, nonEmptyOr(f.Description, "(unknown device)"))
|
||||
if f.Skipped != "" && f.AfterSpeed == "" {
|
||||
fmt.Fprintf(&b, " skipped: %s\n", f.Skipped)
|
||||
continue
|
||||
}
|
||||
verdict := "OK"
|
||||
switch {
|
||||
case f.NotPresent:
|
||||
verdict = "FELL OFF BUS"
|
||||
case f.Degraded:
|
||||
verdict = "DEGRADED"
|
||||
}
|
||||
fmt.Fprintf(&b, " %s: before=%s after=%s max=%s width=%d/%d",
|
||||
verdict, f.BeforeSpeed, f.AfterSpeed, f.MaxSpeed, f.Width, f.MaxWidth)
|
||||
if f.PortMaxSpeed != "" && (f.PortMaxSpeed != f.MaxSpeed || f.PortMaxWidth != f.MaxWidth) {
|
||||
fmt.Fprintf(&b, " (port capability %s x%d, limited by downstream device)", f.PortMaxSpeed, f.PortMaxWidth)
|
||||
}
|
||||
fmt.Fprintln(&b)
|
||||
if f.Skipped != "" {
|
||||
fmt.Fprintf(&b, " note: %s\n", f.Skipped)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassifyGPUFromVendorClass(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
vendor string
|
||||
class string
|
||||
wantIsGPU bool
|
||||
wantVendor string
|
||||
}{
|
||||
{"nvidia GPU", "0x10de", "0x030200", true, "nvidia"},
|
||||
{"amd GPU", "0x1002", "0x030000", true, "amd"},
|
||||
{"nvidia NIC (same vendor, not display class)", "0x10de", "0x020000", false, ""},
|
||||
{"intel NIC", "0x8086", "0x020000", false, ""},
|
||||
{"unrelated display-class vendor", "0x1234", "0x030000", false, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotIsGPU, gotVendor := classifyGPUFromVendorClass(tc.vendor, tc.class)
|
||||
if gotIsGPU != tc.wantIsGPU || gotVendor != tc.wantVendor {
|
||||
t.Fatalf("classifyGPUFromVendorClass(%q,%q) = (%v,%q), want (%v,%q)",
|
||||
tc.vendor, tc.class, gotIsGPU, gotVendor, tc.wantIsGPU, tc.wantVendor)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPCIeBridgeUsesDownstreamCapability(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
portSpeed string
|
||||
portWidth int
|
||||
endpointSpeed string
|
||||
endpointWidth int
|
||||
wantSpeed string
|
||||
wantWidth int
|
||||
}{
|
||||
{"ConnectX-5 behind Gen4 root port", "Gen4", 16, "Gen3", 8, "Gen3", 8},
|
||||
{"Adaptec SAS behind Gen4 root port", "Gen4", 16, "Gen3", 8, "Gen3", 8},
|
||||
{"I350 behind Gen4 root port", "Gen4", 16, "Gen2", 4, "Gen2", 4},
|
||||
{"faster endpoint remains port-limited", "Gen3", 8, "Gen4", 16, "Gen3", 8},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotSpeed := minPCIeLinkSpeed(tc.portSpeed, tc.endpointSpeed)
|
||||
gotWidth := minPositiveInt(tc.portWidth, tc.endpointWidth)
|
||||
if gotSpeed != tc.wantSpeed || gotWidth != tc.wantWidth {
|
||||
t.Fatalf("effective capability = %s x%d, want %s x%d", gotSpeed, gotWidth, tc.wantSpeed, tc.wantWidth)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPCIeBridgeAtEndpointMaximumPasses(t *testing.T) {
|
||||
maxSpeed := minPCIeLinkSpeed("Gen4", "Gen3")
|
||||
maxWidth := minPositiveInt(16, 8)
|
||||
finding := pcieLinkFinding{
|
||||
BDF: "0000:4a:02.0",
|
||||
Description: "Intel root port to ConnectX-5",
|
||||
BeforeSpeed: "Gen3",
|
||||
AfterSpeed: "Gen3",
|
||||
MaxSpeed: maxSpeed,
|
||||
PortMaxSpeed: "Gen4",
|
||||
Width: 8,
|
||||
MaxWidth: maxWidth,
|
||||
PortMaxWidth: 16,
|
||||
Degraded: "Gen3" != maxSpeed,
|
||||
}
|
||||
|
||||
summary := renderPCIeLinkCheckSummary([]pcieLinkFinding{finding})
|
||||
if !strings.Contains(summary, "overall_status=OK") {
|
||||
t.Fatalf("endpoint-limited bridge should pass, got:\n%s", summary)
|
||||
}
|
||||
report := renderPCIeLinkCheckReport([]pcieLinkFinding{finding})
|
||||
if !strings.Contains(report, "limited by downstream device") {
|
||||
t.Fatalf("report should explain the effective maximum, got:\n%s", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownstreamPCIeLinkCapabilityFromSysfsTopology(t *testing.T) {
|
||||
bridgeDir := t.TempDir()
|
||||
childDir := filepath.Join(bridgeDir, "0000:4b:00.0")
|
||||
if err := os.Mkdir(childDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(childDir, "max_link_speed"), []byte("8.0 GT/s PCIe\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(childDir, "max_link_width"), []byte("8\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A non-BDF sysfs entry must not influence peer discovery.
|
||||
if err := os.WriteFile(filepath.Join(bridgeDir, "max_link_speed"), []byte("16.0 GT/s PCIe\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
speed, width, ok := downstreamPCIeLinkCapabilityAt(bridgeDir)
|
||||
if !ok || speed != "Gen3" || width != 8 {
|
||||
t.Fatalf("downstream capability = (%q, %d, %v), want (Gen3, 8, true)", speed, width, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPCIeLinkCheckSummaryDegradedGPU(t *testing.T) {
|
||||
findings := []pcieLinkFinding{
|
||||
{BDF: "0000:0d:00.0", Description: "NVIDIA GPU", IsGPU: true, GPUVendor: "nvidia",
|
||||
BeforeSpeed: "Gen1", AfterSpeed: "Gen1", MaxSpeed: "Gen5", Degraded: true},
|
||||
{BDF: "0000:37:00.0", Description: "NVIDIA GPU", IsGPU: true, GPUVendor: "nvidia",
|
||||
BeforeSpeed: "Gen1", AfterSpeed: "Gen5", MaxSpeed: "Gen5", Degraded: false},
|
||||
{BDF: "0000:01:00.0", Description: "Mellanox NIC", IsGPU: false,
|
||||
BeforeSpeed: "Gen3", AfterSpeed: "Gen4", MaxSpeed: "Gen4", Degraded: false},
|
||||
}
|
||||
summary := renderPCIeLinkCheckSummary(findings)
|
||||
if !strings.Contains(summary, "gpu_nvidia_status=FAILED") {
|
||||
t.Fatalf("expected gpu_nvidia_status=FAILED (one degraded GPU should fail the vendor group), got:\n%s", summary)
|
||||
}
|
||||
if !strings.Contains(summary, "other_status=OK") {
|
||||
t.Fatalf("expected other_status=OK, got:\n%s", summary)
|
||||
}
|
||||
if !strings.Contains(summary, "overall_status=FAILED") {
|
||||
t.Fatalf("expected overall_status=FAILED, got:\n%s", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPCIeLinkCheckSummaryAllClean(t *testing.T) {
|
||||
findings := []pcieLinkFinding{
|
||||
{BDF: "0000:0d:00.0", IsGPU: true, GPUVendor: "nvidia", BeforeSpeed: "Gen1", AfterSpeed: "Gen5", MaxSpeed: "Gen5"},
|
||||
{BDF: "0000:2c:00.0", Skipped: "device disabled (no data traffic; link state has no operational impact)"},
|
||||
}
|
||||
summary := renderPCIeLinkCheckSummary(findings)
|
||||
if !strings.Contains(summary, "overall_status=OK") {
|
||||
t.Fatalf("expected overall_status=OK, got:\n%s", summary)
|
||||
}
|
||||
if strings.Contains(summary, "other_status=") {
|
||||
t.Fatalf("disabled/skipped-only device should not produce an other_status line, got:\n%s", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPCIeLinkCheckSummaryEmptySlotDoesNotFail(t *testing.T) {
|
||||
// An unpopulated switch downstream port (no card ever seated) is a
|
||||
// normal, common configuration — it must not fail the SAT the way a
|
||||
// genuinely degraded link does.
|
||||
findings := []pcieLinkFinding{
|
||||
{BDF: "0000:0d:00.0", IsGPU: true, GPUVendor: "nvidia", BeforeSpeed: "Gen5", AfterSpeed: "Gen5", MaxSpeed: "Gen5"},
|
||||
{BDF: "0000:2a:00.0", Description: "PEX890xx PCIe Gen 5 Switch",
|
||||
NotPresent: true, Skipped: "no device present downstream (empty slot/riser — nothing to retrain)"},
|
||||
}
|
||||
summary := renderPCIeLinkCheckSummary(findings)
|
||||
if !strings.Contains(summary, "overall_status=OK") {
|
||||
t.Fatalf("expected overall_status=OK for an empty slot, got:\n%s", summary)
|
||||
}
|
||||
if strings.Contains(summary, "other_status=") {
|
||||
t.Fatalf("empty-slot-only device should not produce an other_status line, got:\n%s", summary)
|
||||
}
|
||||
|
||||
report := renderPCIeLinkCheckReport(findings)
|
||||
if !strings.Contains(report, "skipped: no device present downstream") {
|
||||
t.Fatalf("expected empty slot to be reported as skipped, not degraded, got:\n%s", report)
|
||||
}
|
||||
if strings.Contains(report, "retrained to") {
|
||||
t.Fatalf("empty slot must not be reported with a fabricated retrain speed, got:\n%s", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPCIeLinkCheckSummaryDeviceFellOffBusStillFails(t *testing.T) {
|
||||
// A device that had a live link before the retrain and is gone right
|
||||
// after it is a real regression, distinct from a slot that was never
|
||||
// populated — this must still fail the check.
|
||||
findings := []pcieLinkFinding{
|
||||
{BDF: "0000:ab:00.0", Description: "PEX890xx PCIe Gen 5 Switch",
|
||||
BeforeSpeed: "Gen5", AfterSpeed: "Gen5", MaxSpeed: "Gen5", Width: 0, NotPresent: true, Degraded: true},
|
||||
}
|
||||
summary := renderPCIeLinkCheckSummary(findings)
|
||||
if !strings.Contains(summary, "overall_status=FAILED") {
|
||||
t.Fatalf("expected overall_status=FAILED when a device disappears after retrain, got:\n%s", summary)
|
||||
}
|
||||
if !strings.Contains(summary, "no device detected downstream") {
|
||||
t.Fatalf("expected an honest 'no device detected' reason, not a fabricated speed, got:\n%s", summary)
|
||||
}
|
||||
|
||||
report := renderPCIeLinkCheckReport(findings)
|
||||
if !strings.Contains(report, "FELL OFF BUS") {
|
||||
t.Fatalf("expected FELL OFF BUS verdict in report, got:\n%s", report)
|
||||
}
|
||||
}
|
||||
@@ -197,6 +197,10 @@ type satJob struct {
|
||||
name string
|
||||
cmd []string
|
||||
env []string // extra env vars (appended to os.Environ)
|
||||
// afterRun executes immediately after the command exits, before health
|
||||
// probes or artifact processing can let a briefly boosted link return to
|
||||
// its idle power state.
|
||||
afterRun func()
|
||||
// validate checks successful command output against the tool's documented
|
||||
// result format. It may inspect artifacts from earlier jobs in runDir.
|
||||
// FAILED means the tool proved a test failure; UNSUPPORTED means the pinned
|
||||
@@ -373,6 +377,9 @@ func runAcceptancePackCtx(ctx context.Context, baseDir, prefix string, jobs []sa
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
if job.afterRun != nil {
|
||||
job.afterRun()
|
||||
}
|
||||
}
|
||||
|
||||
if nvidiaPack && nvidiaJobNeedsHealthCheck(job) {
|
||||
|
||||
@@ -451,6 +451,10 @@ func (s *System) RunNvidiaBandwidthPack(ctx context.Context, baseDir string, gpu
|
||||
return "", err
|
||||
}
|
||||
killStaleNvidiaTestWorkers(logFunc)
|
||||
linkFindings, err := captureNvidiaPCIeLinkBaseline(selected)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
jobs := []satJob{
|
||||
{name: "01-nvidia-smi-q.log", cmd: []string{"nvidia-smi", "-q"}},
|
||||
{name: "02-dcgmi-discovery.log", cmd: []string{"dcgmi", "discovery", "-l"}, informational: true, retries: 2},
|
||||
@@ -476,6 +480,7 @@ func (s *System) RunNvidiaBandwidthPack(ctx context.Context, baseDir string, gpu
|
||||
collectGPU: true,
|
||||
gpuIndices: selected,
|
||||
syncBracket: true,
|
||||
afterRun: func() { sampleNvidiaPCIeLinkAfterLoad(linkFindings) },
|
||||
})
|
||||
step++
|
||||
} else {
|
||||
@@ -495,15 +500,17 @@ func (s *System) RunNvidiaBandwidthPack(ctx context.Context, baseDir string, gpu
|
||||
collectGPU: true,
|
||||
gpuIndices: selected,
|
||||
syncBracket: true,
|
||||
afterRun: func() { sampleNvidiaPCIeLinkAfterLoad(linkFindings) },
|
||||
})
|
||||
step++
|
||||
}
|
||||
jobs = append(jobs, satJob{
|
||||
name: fmt.Sprintf("%02d-nvidia-smi-after.log", step),
|
||||
cmd: []string{"nvidia-smi", "--query-gpu=index,name,temperature.gpu,power.draw,utilization.gpu,memory.used,memory.total", "--format=csv,noheader,nounits"},
|
||||
})
|
||||
|
||||
return runAcceptancePackCtx(ctx, baseDir, "gpu-nvidia-bandwidth", withNvidiaPersistenceMode(jobs...), logFunc)
|
||||
runDir, err := runAcceptancePackCtx(ctx, baseDir, "gpu-nvidia-bandwidth", withNvidiaPersistenceMode(jobs...), logFunc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := finishNvidiaPCIeLinkCheck(runDir, linkFindings); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return runDir, nil
|
||||
}
|
||||
|
||||
func (s *System) RunNvidiaAcceptancePack(baseDir string, logFunc func(string)) (string, error) {
|
||||
|
||||
Reference in New Issue
Block a user