package platform import ( "bufio" "bytes" "context" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "sync" "syscall" "time" ) // Estimated wall-clock durations for each SAT/validate test, derived from real // production logs in _benchmark/_v8/. // // Rule: whenever the commands, timeout parameters, or number of sub-jobs inside // the corresponding Run*Pack function change, re-measure the wall-clock duration // from actual task logs and update the matching constant here. // // Sources: // - SATEstimatedCPUValidateSec: xFusion v8.6 — 62 s // - SATEstimatedMemoryValidateSec: xFusion v8.6 — 68 s // - SATEstimatedNvidiaGPUValidateSec: xFusion v8.6/v8.22 — 77–87 s/GPU (measured per-GPU; re-measure after switch to all-GPU simultaneous) // - SATEstimatedNvidiaGPUStressSec: xFusion v8.6/v8.22 — 444–448 s/GPU (measured per-GPU; re-measure after switch to all-GPU simultaneous) // - SATEstimatedNvidiaTargetedStressSec: xFusion v8.6/v8.22 — 347–348 s/GPU (measured per-GPU; re-measure after switch to all-GPU simultaneous) // - SATEstimatedNvidiaTargetedPowerSec: MSI v8.22 / xFusion v8.6 — 346–351 s/GPU (measured per-GPU; re-measure after switch to all-GPU simultaneous) // - SATEstimatedNvidiaPulseTestSec: xFusion v8.6 — 4 926 s / 8 GPU (all simultaneous) // - SATEstimatedNvidiaInterconnectSec: xFusion v8.6/v8.22 — 210–384 s / 8 GPU (all simultaneous) // - SATEstimatedNvidiaBandwidthSec: xFusion v8.6/v8.22 — 2 664–2 688 s / 8 GPU (all simultaneous); // on multi-socket systems now runs as up to 3 passes (per-socket + all-GPU) — re-measure and bump this once // real multi-socket task logs exist, current value only covers the single-pass/single-socket case. const ( // CPU stress: stress-ng 60 s + lscpu/sensors overhead. SATEstimatedCPUValidateSec = 65 // CPU stress: stress-ng 1800 s (stress mode default). SATEstimatedCPUStressSec = 1800 // RAM: memtester 256 MB / 1 pass. SATEstimatedMemoryValidateSec = 70 // RAM: memtester 512 MB / 1 pass (extrapolated from validate timing, linear with size). SATEstimatedMemoryStressSec = 140 // TPM capabilities, PCR values, and existing self-test result queries. SATEstimatedTPMValidateSec = 5 // NVIDIA dcgmi diag Level 2 (medium), all GPUs simultaneously. SATEstimatedNvidiaGPUValidateSec = 85 // NVIDIA dcgmi diag Level 3 (targeted stress), all GPUs simultaneously. SATEstimatedNvidiaGPUStressSec = 450 // NVIDIA dcgmi targeted_stress 300 s + overhead, all GPUs simultaneously. SATEstimatedNvidiaTargetedStressSec = 350 // NVIDIA dcgmi targeted_power 300 s + overhead, all GPUs simultaneously. SATEstimatedNvidiaTargetedPowerSec = 350 // NVIDIA dcgmi pulse_test, all GPUs simultaneously (not per-GPU). SATEstimatedNvidiaPulseTestSec = 5000 // NCCL all_reduce_perf, all GPUs simultaneously. SATEstimatedNvidiaInterconnectSec = 300 // nvbandwidth, all GPUs simultaneously. Tool runs all built-in tests // without a user-configurable time limit; duration is determined by nvbandwidth itself. SATEstimatedNvidiaBandwidthSec = 2700 ) // satJobBoundaryHook, if set, is called with a SAT job's log file name right // after that job's output has been written to disk — a natural point for an // external blackbox sync to pick up newly-completed data promptly instead of // waiting out its own adaptive schedule. Nil by default (no-op); set once // via SetJobBoundaryHook by whichever process wires up blackbox. var satJobBoundaryHook func(jobName string) // SetJobBoundaryHook installs the callback invoked after each SAT job // finishes and its log file has been written. Pass nil to clear it. func SetJobBoundaryHook(hook func(jobName string)) { satJobBoundaryHook = hook } // satSyncBracketHook, if set, is called synchronously immediately before and // after a satJob marked syncBracket runs — i.e. around the actual load // command of a diagnostic (nvbandwidth, memtester, stress-ng, dcgmi diag...), // not the cheap discovery/inventory steps around it. phase is "before" or // "after". Unlike satJobBoundaryHook (fire-and-forget, fires after every // job), this is meant to block until an external blackbox sync has actually // reached removable media, so that a crash during the load itself still // leaves durable evidence that the load started (and, on the far side, that // it finished). Nil by default. An error is logged, never fails the job — // a stuck blackbox target must not block the diagnostic the operator asked // for. var satSyncBracketHook func(jobName, phase string) error // SetSyncBracketHook installs the callback invoked synchronously before and // after a syncBracket-marked SAT job. Pass nil to clear it. func SetSyncBracketHook(hook func(jobName, phase string) error) { satSyncBracketHook = hook } func runSyncBracketHook(job satJob, phase string, logFunc func(string)) { if !job.syncBracket || satSyncBracketHook == nil { return } if err := satSyncBracketHook(job.name, phase); err != nil && logFunc != nil { logFunc(fmt.Sprintf("%s: blackbox sync wait (%s) did not complete cleanly: %v", job.name, phase, err)) } } var ( satExecCommand = exec.Command satLookPath = exec.LookPath satGlob = filepath.Glob satStat = os.Stat satFreeMemBytes = freeMemBytes rocmSMIExecutableGlobs = []string{ "/opt/rocm/bin/rocm-smi", "/opt/rocm-*/bin/rocm-smi", } rocmSMIScriptGlobs = []string{ "/opt/rocm/libexec/rocm_smi/rocm_smi.py", "/opt/rocm-*/libexec/rocm_smi/rocm_smi.py", } rvsExecutableGlobs = []string{ "/opt/rocm/bin/rvs", "/opt/rocm-*/bin/rvs", } dcgmProfTesterCandidates = []string{ "dcgmproftester", "dcgmproftester13", "dcgmproftester12", "dcgmproftester11", } ) // streamExecOutput runs cmd and streams each output line to logFunc (if non-nil). // If livePath is non-empty, each line is also appended to that file as it // arrives — so the command's own output already exists on disk (and is thus // pickable up by a concurrent blackbox sync) while it's still running, // instead of only appearing once the whole pack finishes writing the final // job file. Best-effort: a failure to open/write livePath never fails the job. // Returns combined stdout+stderr as a byte slice. func streamExecOutput(cmd *exec.Cmd, logFunc func(string), livePath string) ([]byte, error) { pr, pw := io.Pipe() cmd.Stdout = pw cmd.Stderr = pw var liveFile *os.File if livePath != "" { if f, err := os.OpenFile(livePath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644); err == nil { liveFile = f } } var buf bytes.Buffer var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() if liveFile != nil { defer liveFile.Close() } scanner := bufio.NewScanner(pr) for scanner.Scan() { line := scanner.Text() buf.WriteString(line + "\n") if liveFile != nil { _, _ = liveFile.WriteString(line + "\n") } if logFunc != nil { logFunc(line) } } }() err := cmd.Start() if err != nil { _ = pw.Close() wg.Wait() return nil, err } waitErr := cmd.Wait() _ = pw.Close() wg.Wait() return buf.Bytes(), waitErr } // satJob describes one command and the checks needed to turn its process and // output results into a SAT verdict. type satJob struct { name string cmd []string env []string // extra env vars (appended to os.Environ) // 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 // output contract could not be recognized safely. validate func(runDir string, out []byte) (status, detail string) collectGPU bool // collect GPU metrics via nvidia-smi while this job runs gpuIndices []int // GPU indices to collect metrics for (empty = all) // informational marks a preflight/metadata job (e.g. dcgmi discovery) whose // failure shouldn't flip the pack's overall status — the diagnostic jobs // that follow it are the actual test of GPU health. informational bool // retries is the number of extra attempts (with a short backoff) if the // job's first run fails. Used for jobs racing nv-hostengine startup. retries int // syncBracket marks a job as the actual load step of a pack (as opposed // to the cheap inventory/discovery steps around it) — see // satSyncBracketHook. Set this on the command that can hang or crash the // host, not on nvidia-smi/dcgmi discovery calls. syncBracket bool } type satStats struct { OK int Failed int Unsupported int Informational int } func withNvidiaPersistenceMode(jobs ...satJob) []satJob { out := make([]satJob, 0, len(jobs)+1) out = append(out, satJob{ name: "00-nvidia-smi-persistence-mode.log", cmd: []string{"nvidia-smi", "-pm", "1"}, }) out = append(out, jobs...) return out } func nvidiaSATJobs() []satJob { return withNvidiaPersistenceMode( satJob{name: "01-nvidia-smi-q.log", cmd: []string{"nvidia-smi", "-q"}}, satJob{name: "02-dmidecode-baseboard.log", cmd: []string{"dmidecode", "-t", "baseboard"}}, satJob{name: "03-dmidecode-system.log", cmd: []string{"dmidecode", "-t", "system"}}, // nvidia-bug-report.sh appends .gz to --output-file whenever gzip is // available, so the artifact actually lands at nvidia-bug-report.log.gz. satJob{name: "04-nvidia-bug-report.log.gz", cmd: []string{"nvidia-bug-report.sh", "--output-file", "{{run_dir}}/nvidia-bug-report.log"}}, satJob{name: "05-bee-gpu-burn.log", cmd: []string{"bee-gpu-burn", "--seconds", "5", "--size-mb", "64"}, syncBracket: true}, ) } func nvidiaDCGMJobs(diagLevel int, gpuIndices []int) []satJob { if diagLevel < 1 || diagLevel > 4 { diagLevel = 3 } diagArgs := append([]string{"dcgmi", "diag", "-r", strconv.Itoa(diagLevel)}, nvidiaDCGMDiagDebugArgs("dcgmi-diag")...) if len(gpuIndices) > 0 { ids := make([]string, len(gpuIndices)) for i, idx := range gpuIndices { ids[i] = strconv.Itoa(idx) } diagArgs = append(diagArgs, "-i", strings.Join(ids, ",")) } return withNvidiaPersistenceMode( satJob{name: "01-nvidia-smi-q.log", cmd: []string{"nvidia-smi", "-q"}}, satJob{name: "02-dmidecode-baseboard.log", cmd: []string{"dmidecode", "-t", "baseboard"}}, satJob{name: "03-dmidecode-system.log", cmd: []string{"dmidecode", "-t", "system"}}, satJob{name: "04-dcgmi-discovery.log", cmd: []string{"dcgmi", "discovery", "-l"}, informational: true, retries: 2}, satJob{name: "05-dcgmi-diag.log", cmd: diagArgs, gpuIndices: gpuIndices, syncBracket: true}, ) } // nvidiaDCGMDiagDebugArgs returns flags that make dcgmi diag emit its full // internal nvvs debug log (e.g. the real reason behind a terse "Detected // driver major version 0" failure) into the SAT run dir instead of the // default /var/log/nvidia-dcgm/nvvs.log, which may not be captured otherwise. func nvidiaDCGMDiagDebugArgs(logPrefix string) []string { return []string{"-v", "-d", "DEBUG", "--debugLogFile", "{{run_dir}}/" + logPrefix + "-debug.log"} } func nvidiaDCGMNamedDiagCommand(name string, durationSec int, gpuIndices []int) []string { args := append([]string{"dcgmi", "diag", "-r", name}, nvidiaDCGMDiagDebugArgs("dcgmi-"+strings.ReplaceAll(name, "_", "-"))...) if durationSec > 0 { args = append(args, "-p", fmt.Sprintf("%s.test_duration=%d", name, durationSec)) } if len(gpuIndices) > 0 { args = append(args, "-i", joinIndexList(gpuIndices)) } return args } func normalizeNvidiaBurnDuration(durationSec int) int { if durationSec <= 0 { return 300 } return durationSec } func nvidiaVisibleDevicesEnv(gpuIndices []int) []string { if len(gpuIndices) == 0 { return nil } return []string{ "CUDA_DEVICE_ORDER=PCI_BUS_ID", "CUDA_VISIBLE_DEVICES=" + joinIndexList(gpuIndices), } } func runAcceptancePackCtx(ctx context.Context, baseDir, prefix string, jobs []satJob, 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, prefix+"-"+ts) if err := os.MkdirAll(runDir, 0755); err != nil { return "", err } verboseLog := filepath.Join(runDir, "verbose.log") var summary strings.Builder stats := satStats{} nvidiaPack := isNvidiaAcceptancePack(prefix) perGPU := map[int]*nvidiaGPUStatusFile{} selectedGPUIndices := map[int]struct{}{} fmt.Fprintf(&summary, "run_at_utc=%s\n", time.Now().UTC().Format(time.RFC3339)) for _, job := range jobs { if ctx.Err() != nil { break } for _, idx := range job.gpuIndices { selectedGPUIndices[idx] = struct{}{} status := perGPU[idx] if status == nil { status = &nvidiaGPUStatusFile{Index: idx} perGPU[idx] = status } status.Selected = true } cmd := make([]string, 0, len(job.cmd)) for _, arg := range job.cmd { cmd = append(cmd, strings.ReplaceAll(arg, "{{run_dir}}", runDir)) } var out []byte var err error jobDetail := "" if nvidiaPack && nvidiaJobNeedsHealthCheck(job) { if msg, healthErr := checkNvidiaJobHealth(job.gpuIndices); healthErr != nil { if logFunc != nil { logFunc(msg) } out = []byte(msg + "\n") err = healthErr jobDetail = msg } } if err == nil { runSyncBracketHook(job, "before", logFunc) for attempt := 0; ; attempt++ { if job.collectGPU { out, err = runSATCommandWithMetrics(ctx, verboseLog, job.name, cmd, job.env, job.gpuIndices, runDir, logFunc) } else { out, err = runSATCommandCtx(ctx, verboseLog, job.name, cmd, job.env, logFunc, filepath.Join(runDir, job.name)) } if err == nil || attempt >= job.retries || ctx.Err() != nil { break } if logFunc != nil { logFunc(fmt.Sprintf("%s: retrying after failure (attempt %d/%d)", job.name, attempt+1, job.retries)) } time.Sleep(2 * time.Second) } } if nvidiaPack && nvidiaJobNeedsHealthCheck(job) { if msg, healthErr := checkNvidiaJobHealth(job.gpuIndices); healthErr != nil { if logFunc != nil { logFunc(msg) } if len(out) > 0 && !bytes.HasSuffix(out, []byte("\n")) { out = append(out, '\n') } out = append(out, []byte(msg+"\n")...) if err == nil { err = healthErr } jobDetail = msg } } status, rc := classifySATResult(job.name, out, err) validationDetail := singleLineSATDetail(jobDetail) validated := false if status == "OK" && job.validate != nil { status, validationDetail = validateSATJobOutput(job, runDir, out) validated = true } if validated { if validationDetail != "" { if len(out) > 0 && !bytes.HasSuffix(out, []byte("\n")) { out = append(out, '\n') } out = append(out, []byte(fmt.Sprintf("[bee-validator] %s: %s\n", status, validationDetail))...) } } if writeErr := os.WriteFile(filepath.Join(runDir, job.name), out, 0644); writeErr != nil { return "", writeErr } if satJobBoundaryHook != nil { satJobBoundaryHook(job.name) } runSyncBracketHook(job, "after", logFunc) if ctx.Err() != nil { return "", ctx.Err() } if job.informational && status != "OK" { stats.Informational++ } else { stats.Add(status) } if nvidiaPack && len(job.gpuIndices) > 0 && nvidiaJobNeedsHealthCheck(job) { for _, idx := range job.gpuIndices { updateNvidiaGPUStatus(perGPU, idx, status, job.name, string(out)) } } key := strings.TrimSuffix(strings.TrimPrefix(job.name, "0"), ".log") fmt.Fprintf(&summary, "%s_rc=%d\n", key, rc) fmt.Fprintf(&summary, "%s_status=%s\n", key, status) if validationDetail != "" { fmt.Fprintf(&summary, "%s_detail=%s\n", key, singleLineSATDetail(validationDetail)) } } writeSATStats(&summary, stats) if err := os.WriteFile(filepath.Join(runDir, "summary.txt"), []byte(summary.String()), 0644); err != nil { return "", err } if nvidiaPack { if err := writeNvidiaGPUStatusFiles(runDir, stats.Overall(), perGPU, selectedGPUIndices); err != nil { return "", err } } return runDir, nil } func isNvidiaAcceptancePack(prefix string) bool { return strings.HasPrefix(prefix, "gpu-nvidia") || prefix == "nccl-tests" } func validateSATJobOutput(job satJob, runDir string, out []byte) (string, string) { status, detail := job.validate(runDir, out) status = strings.ToUpper(strings.TrimSpace(status)) switch status { case "OK", "FAILED", "UNSUPPORTED", "PARTIAL": return status, strings.TrimSpace(detail) default: return "UNSUPPORTED", fmt.Sprintf("validator returned invalid status %q", status) } } func singleLineSATDetail(detail string) string { return strings.Join(strings.Fields(detail), " ") } func updateNvidiaGPUStatus(perGPU map[int]*nvidiaGPUStatusFile, idx int, status, jobName, detail string) { entry := perGPU[idx] if entry == nil { entry = &nvidiaGPUStatusFile{Index: idx} perGPU[idx] = entry } if nvidiaSATStatusSeverity(status) >= nvidiaSATStatusSeverity(entry.RunStatus) { entry.RunStatus = status entry.FailingJob = jobName entry.Reason = firstLine(detail) } } func writeNvidiaGPUStatusFiles(runDir, overall string, perGPU map[int]*nvidiaGPUStatusFile, selected map[int]struct{}) error { health, err := readNvidiaGPUHealth() if err == nil { for _, gpu := range health { entry := perGPU[gpu.Index] if entry == nil { entry = &nvidiaGPUStatusFile{Index: gpu.Index} perGPU[gpu.Index] = entry } entry.Name = gpu.Name entry.Observed = true entry.HealthRaw = gpu.RawLine if gpu.NeedsReset { entry.Health = "RESET_REQUIRED" if entry.RunStatus == "" || nvidiaSATStatusSeverity("FAILED") >= nvidiaSATStatusSeverity(entry.RunStatus) { entry.RunStatus = "FAILED" if strings.TrimSpace(entry.Reason) == "" { entry.Reason = "GPU requires reset" } } } else { entry.Health = "OK" } } } for idx := range selected { entry := perGPU[idx] if entry == nil { entry = &nvidiaGPUStatusFile{Index: idx} perGPU[idx] = entry } entry.Selected = true } var indices []int for idx := range perGPU { indices = append(indices, idx) } sort.Ints(indices) for _, idx := range indices { entry := perGPU[idx] if entry.RunStatus == "" { entry.RunStatus = overall } if entry.Health == "" { entry.Health = "UNKNOWN" } if entry.Name == "" { entry.Name = "Unknown GPU" } var body strings.Builder fmt.Fprintf(&body, "gpu_index=%d\n", entry.Index) fmt.Fprintf(&body, "gpu_name=%s\n", entry.Name) fmt.Fprintf(&body, "selected=%t\n", entry.Selected) fmt.Fprintf(&body, "observed=%t\n", entry.Observed) fmt.Fprintf(&body, "run_status=%s\n", entry.RunStatus) fmt.Fprintf(&body, "health_status=%s\n", entry.Health) if strings.TrimSpace(entry.FailingJob) != "" { fmt.Fprintf(&body, "failing_job=%s\n", entry.FailingJob) } if strings.TrimSpace(entry.Reason) != "" { fmt.Fprintf(&body, "reason=%s\n", entry.Reason) } if strings.TrimSpace(entry.HealthRaw) != "" { fmt.Fprintf(&body, "health_raw=%s\n", entry.HealthRaw) } if err := os.WriteFile(filepath.Join(runDir, fmt.Sprintf("gpu-%d-status.txt", idx)), []byte(body.String()), 0644); err != nil { return err } } return nil } // runSATCommandCtx runs cmd and returns its combined output. livePath is // variadic purely so existing callers are unaffected: pass a path (job's // output file) to also stream output to disk live as it runs, so a crash // mid-command leaves whatever had printed so far instead of nothing at all. func runSATCommandCtx(ctx context.Context, verboseLog, name string, cmd []string, env []string, logFunc func(string), livePath ...string) ([]byte, error) { start := time.Now().UTC() resolvedCmd, err := resolveSATCommand(cmd) appendSATVerboseLog(verboseLog, fmt.Sprintf("[%s] start %s", start.Format(time.RFC3339), name), "cmd: "+strings.Join(resolvedCmd, " "), ) if logFunc != nil { logFunc(fmt.Sprintf("=== %s ===", name)) } if err != nil { appendSATVerboseLog(verboseLog, fmt.Sprintf("[%s] finish %s", time.Now().UTC().Format(time.RFC3339), name), "rc: 1", fmt.Sprintf("duration_ms: %d", time.Since(start).Milliseconds()), "", ) return []byte(err.Error() + "\n"), err } c := exec.CommandContext(ctx, resolvedCmd[0], resolvedCmd[1:]...) c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} c.Cancel = func() error { if c.Process != nil { _ = syscall.Kill(-c.Process.Pid, syscall.SIGKILL) } return nil } if len(env) > 0 { c.Env = append(os.Environ(), env...) } var live string if len(livePath) > 0 { live = livePath[0] } out, err := streamExecOutput(c, logFunc, live) rc := 0 if err != nil { rc = 1 } appendSATVerboseLog(verboseLog, fmt.Sprintf("[%s] finish %s", time.Now().UTC().Format(time.RFC3339), name), fmt.Sprintf("rc: %d", rc), fmt.Sprintf("duration_ms: %d", time.Since(start).Milliseconds()), "", ) return out, err } // smartctlSelfTestPollInterval/Timeout bound how long we poll the drive after // launching `smartctl -t short`, which SMART/ATA specs put at ~2 minutes. const ( smartctlSelfTestPollInterval = 5 * time.Second smartctlSelfTestTimeout = 4 * time.Minute ) // waitForSmartctlSelfTest polls `smartctl -a` until the short self-test // started on devPath finishes (or the timeout/context elapses) and returns // the final output, which reflects the actual test result rather than the // "Testing has begun" launch acknowledgement. func waitForSmartctlSelfTest(ctx context.Context, verboseLog, devPath string, logFunc func(string)) []byte { deadline := time.Now().Add(smartctlSelfTestTimeout) var last []byte for { out, _ := runSATCommandCtx(ctx, verboseLog, "smartctl-self-test-status", []string{"smartctl", "-a", devPath}, nil, nil) last = out if ctx.Err() != nil { return last } lower := bytes.ToLower(out) if !bytes.Contains(lower, []byte("self-test routine in progress")) && !bytes.Contains(lower, []byte("% of test remaining")) { return last } if time.Now().After(deadline) { return last } select { case <-ctx.Done(): return last case <-time.After(smartctlSelfTestPollInterval): } } } func listStorageDevices() ([]string, error) { out, err := satExecCommand("lsblk", "-dn", "-o", "NAME,TYPE,TRAN").Output() if err != nil { return nil, err } return parseStorageDevices(string(out)), nil } // storageSATCommands returns the commands to run for a single storage device. // extended=false (Check): read-only SMART/NVMe data collection, no self-test. // extended=true (Load): data collection + short self-test. func storageSATCommands(devPath string, extended bool) []satJob { if strings.Contains(filepath.Base(devPath), "nvme") { jobs := []satJob{ {name: "nvme-id-ctrl", cmd: []string{"nvme", "id-ctrl", devPath, "-o", "json"}}, {name: "nvme-smart-log", cmd: []string{"nvme", "smart-log", devPath, "-o", "json"}}, } if extended { jobs = append(jobs, satJob{name: "nvme-device-self-test", cmd: []string{"nvme", "device-self-test", devPath, "-s", "1", "--wait"}, syncBracket: true}) } return jobs } jobs := []satJob{ {name: "smartctl-health", cmd: []string{"smartctl", "-H", "-A", "-i", devPath}}, } if extended { jobs = append(jobs, satJob{name: "smartctl-self-test-short", cmd: []string{"smartctl", "-t", "short", devPath}, syncBracket: true}) } return jobs } func (s *satStats) Add(status string) { switch status { case "OK": s.OK++ case "UNSUPPORTED", "PARTIAL": s.Unsupported++ default: s.Failed++ } } func (s satStats) Overall() string { if s.Failed > 0 { return "FAILED" } if s.Unsupported > 0 { return "PARTIAL" } return "OK" } func writeSATStats(summary *strings.Builder, stats satStats) { fmt.Fprintf(summary, "overall_status=%s\n", stats.Overall()) fmt.Fprintf(summary, "job_ok=%d\n", stats.OK) fmt.Fprintf(summary, "job_failed=%d\n", stats.Failed) fmt.Fprintf(summary, "job_unsupported=%d\n", stats.Unsupported) fmt.Fprintf(summary, "job_informational_failed=%d\n", stats.Informational) } func classifySATResult(name string, out []byte, err error) (string, int) { rc := 0 if err != nil { rc = 1 } if err == nil { return "OK", rc } text := strings.ToLower(string(out)) // No output at all means the tool failed to start (mlock limit, binary missing, // etc.) — we cannot say anything about hardware health → UNSUPPORTED. if len(strings.TrimSpace(text)) == 0 { return "UNSUPPORTED", rc } if strings.Contains(text, "unsupported") || strings.Contains(text, "not supported") || strings.Contains(text, "not found in path") || strings.Contains(text, "invalid opcode") || strings.Contains(text, "unknown command") || strings.Contains(text, "not implemented") || strings.Contains(text, "not available") || strings.Contains(text, "cuda_error_system_not_ready") || strings.Contains(text, "no such device") || // nvidia-smi on a machine with no NVIDIA GPU strings.Contains(text, "couldn't communicate with the nvidia driver") || strings.Contains(text, "no nvidia gpu") || // Some NVMe firmwares start self-test but never expose progress to nvme-cli // while waiting, so the CLI stops polling without proving device failure. (strings.Contains(name, "self-test") && strings.Contains(text, "no progress for") && strings.Contains(text, "stop waiting")) || (strings.Contains(name, "self-test") && strings.Contains(text, "aborted")) { return "UNSUPPORTED", rc } return "FAILED", rc } func hasSMARTOverallHealth(out []byte) bool { m := smartHealthRE.FindStringSubmatch(string(out)) return len(m) > 1 && strings.TrimSpace(m[1]) != "" } func runROCmSMI(args ...string) ([]byte, error) { cmd, err := resolveROCmSMICommand(args...) if err != nil { return nil, err } return satExecCommand(cmd[0], cmd[1:]...).CombinedOutput() } func resolveSATCommand(cmd []string) ([]string, error) { if len(cmd) == 0 { return nil, errors.New("empty SAT command") } switch cmd[0] { case "rocm-smi": return resolveROCmSMICommand(cmd[1:]...) case "rvs": return resolveRVSCommand(cmd[1:]...) } path, err := satLookPath(cmd[0]) if err != nil { return nil, fmt.Errorf("%s not found in PATH: %w", cmd[0], err) } return append([]string{path}, cmd[1:]...), nil } func resolveRVSCommand(args ...string) ([]string, error) { if path, err := satLookPath("rvs"); err == nil { return append([]string{path}, args...), nil } for _, path := range expandExistingPaths(rvsExecutableGlobs) { return append([]string{path}, args...), nil } return nil, errors.New("rvs not found in PATH or under /opt/rocm") } func resolveROCmSMICommand(args ...string) ([]string, error) { if path, err := satLookPath("rocm-smi"); err == nil { return append([]string{path}, args...), nil } for _, path := range rocmSMIExecutableCandidates() { return append([]string{path}, args...), nil } pythonPath, pyErr := satLookPath("python3") if pyErr == nil { for _, script := range rocmSMIScriptCandidates() { cmd := []string{pythonPath, script} cmd = append(cmd, args...) return cmd, nil } } return nil, errors.New("rocm-smi not found in PATH or under /opt/rocm") } func resolveDCGMProfTesterCommand(args ...string) ([]string, error) { for _, candidate := range dcgmProfTesterCandidates { if path, err := satLookPath(candidate); err == nil { return append([]string{path}, args...), nil } } return nil, errors.New("dcgmproftester not found in PATH") } func ensureAMDRuntimeReady() error { if _, err := os.Stat("/dev/kfd"); err == nil { return nil } if raw, err := os.ReadFile("/sys/module/amdgpu/initstate"); err == nil { state := strings.TrimSpace(string(raw)) if strings.EqualFold(state, "live") { return nil } return fmt.Errorf("AMD driver is present but not initialized: amdgpu initstate=%q", state) } return errors.New("AMD GPUs are present but the runtime is not initialized: /dev/kfd is missing and amdgpu is not loaded") } func rocmSMIExecutableCandidates() []string { return expandExistingPaths(rocmSMIExecutableGlobs) } func rocmSMIScriptCandidates() []string { return expandExistingPaths(rocmSMIScriptGlobs) } func expandExistingPaths(patterns []string) []string { seen := make(map[string]struct{}) var paths []string for _, pattern := range patterns { matches, err := satGlob(pattern) if err != nil { continue } sort.Strings(matches) for _, match := range matches { if _, err := satStat(match); err != nil { continue } if _, ok := seen[match]; ok { continue } seen[match] = struct{}{} paths = append(paths, match) } } return paths } func parseStorageDevices(raw string) []string { var devices []string for _, line := range strings.Split(strings.TrimSpace(raw), "\n") { fields := strings.Fields(strings.TrimSpace(line)) if len(fields) < 2 || fields[1] != "disk" { continue } if len(fields) >= 3 && strings.EqualFold(fields[2], "usb") { continue } devices = append(devices, "/dev/"+fields[0]) } return devices } // runSATCommandWithMetrics runs a command while collecting GPU metrics in the background. // On completion it writes gpu-metrics.csv and gpu-metrics.html into runDir. func runSATCommandWithMetrics(ctx context.Context, verboseLog, name string, cmd []string, env []string, gpuIndices []int, runDir string, logFunc func(string)) ([]byte, error) { stopCh := make(chan struct{}) doneCh := make(chan struct{}) var metricRows []GPUMetricRow start := time.Now() go func() { defer close(doneCh) ticker := time.NewTicker(time.Second) defer ticker.Stop() for { select { case <-stopCh: return case <-ticker.C: samples, err := sampleGPUMetrics(gpuIndices) if err != nil { continue } elapsed := time.Since(start).Seconds() for i := range samples { samples[i].ElapsedSec = elapsed } metricRows = append(metricRows, samples...) } } }() out, err := runSATCommandCtx(ctx, verboseLog, name, cmd, env, logFunc, filepath.Join(runDir, name)) close(stopCh) <-doneCh if len(metricRows) > 0 { _ = WriteGPUMetricsCSV(filepath.Join(runDir, "gpu-metrics.csv"), metricRows) _ = WriteGPUMetricsHTML(filepath.Join(runDir, "gpu-metrics.html"), metricRows) } return out, err } func appendSATVerboseLog(path string, lines ...string) { if path == "" { return } f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) if err != nil { return } defer f.Close() for _, line := range lines { _, _ = io.WriteString(f, line+"\n") } } func envInt(name string, fallback int) int { raw := strings.TrimSpace(os.Getenv(name)) if raw == "" { return fallback } value, err := strconv.Atoi(raw) if err != nil || value <= 0 { return fallback } return value }