The fan-ceiling check, the webui metrics collector (every 5s) and the PSU health poller each shelled out to ipmitool independently. The BMC's KCS interface serializes those calls anyway, so under load the concurrent `ipmitool sdr`/`dcmi power reading` invocations just queued behind each other — that's what produced "IPMI slow" backoff during a fan-ceiling run in a blackbox dump, while the dashboard looked fine only because it was reading its own, separately-stale data from a different ipmitool call. hw_telemetry.go is now the sole recurring poller (fan RPM, temperature, PSU power/status, DCMI system power), with the adaptive 1s-30s backoff that used to be duplicated inside the fan check. Every hot-path consumer reads the shared cache (hwSnapshot / platform.HardwareSDRSnapshot) instead of calling ipmitool itself. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
897 lines
29 KiB
Go
897 lines
29 KiB
Go
package platform
|
||
|
||
import (
|
||
"bee/audit/internal/collector"
|
||
"context"
|
||
"fmt"
|
||
"math"
|
||
"os"
|
||
"os/exec"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
func detectDCMIPartialCoverage(sp *BenchmarkServerPower) bool {
|
||
if sp == nil || !sp.Available {
|
||
return false
|
||
}
|
||
if sp.PSUInputIdleW > 0 && sp.IdleW > 0 {
|
||
return sp.IdleW/sp.PSUInputIdleW < 0.7
|
||
}
|
||
if sp.PSUInputLoadedW > 0 && sp.LoadedW > 0 {
|
||
return sp.LoadedW/sp.PSUInputLoadedW < 0.7
|
||
}
|
||
return false
|
||
}
|
||
|
||
// detectIPMISaturationFallback is the heuristic used when SDR PSU sensors are
|
||
// absent. It analyses the power ramp: if 2+ of the last 3 per-step incremental
|
||
// DCMI/GPU ratios fall below 25% of the first-step ratio, DCMI has likely
|
||
// plateaued while GPU load continued to grow (saturation proxy).
|
||
// Prefer detectDCMIPartialCoverage when SDR data is available.
|
||
func detectIPMISaturationFallback(steps []NvidiaPowerBenchStep) bool {
|
||
type pt struct{ incIPMI, incGPU float64 }
|
||
var pts []pt
|
||
for i := 1; i < len(steps); i++ {
|
||
if steps[i].ServerDeltaW <= 0 || steps[i-1].ServerDeltaW <= 0 {
|
||
continue
|
||
}
|
||
incIPMI := steps[i].ServerDeltaW - steps[i-1].ServerDeltaW
|
||
incGPU := steps[i].TotalObservedPowerW - steps[i-1].TotalObservedPowerW
|
||
if incGPU <= 0 {
|
||
continue
|
||
}
|
||
pts = append(pts, pt{incIPMI, incGPU})
|
||
}
|
||
if len(pts) < 3 {
|
||
return false
|
||
}
|
||
refRatio := pts[0].incIPMI / pts[0].incGPU
|
||
if refRatio <= 0 {
|
||
return false
|
||
}
|
||
saturated := 0
|
||
for _, p := range pts[len(pts)-3:] {
|
||
if p.incIPMI/p.incGPU < refRatio*0.25 {
|
||
saturated++
|
||
}
|
||
}
|
||
return saturated >= 2
|
||
}
|
||
|
||
// psuStatusSnapshot samples PSU health sensor states via
|
||
// `ipmitool sdr type "Power Supply"`. Returns a map of sensor name → reading
|
||
// string (e.g. "Presence detected", "Failure detected"). Returns nil when IPMI
|
||
// is unavailable or no Power Supply entity sensors are present.
|
||
func psuStatusSnapshot() map[string]string {
|
||
out, err := exec.Command("ipmitool", "sdr", "type", "Power Supply").Output()
|
||
if err != nil || len(out) == 0 {
|
||
return nil
|
||
}
|
||
result := make(map[string]string)
|
||
for _, line := range strings.Split(string(out), "\n") {
|
||
parts := strings.Split(line, "|")
|
||
if len(parts) < 5 {
|
||
continue
|
||
}
|
||
name := strings.TrimSpace(parts[0])
|
||
reading := strings.TrimSpace(parts[4])
|
||
if name == "" {
|
||
continue
|
||
}
|
||
result[name] = reading
|
||
}
|
||
return result
|
||
}
|
||
|
||
// diffPSUStatus compares PSU sensor snapshots taken before and after a test.
|
||
// Returns human-readable fault strings for sensors that entered a fault state
|
||
// during the test. Pre-existing faults (present in both snapshots) are excluded
|
||
// so that only new anomalies caused by the test are reported.
|
||
func diffPSUStatus(before, after map[string]string) []string {
|
||
if len(after) == 0 {
|
||
return nil
|
||
}
|
||
isFault := func(s string) bool {
|
||
lower := strings.ToLower(s)
|
||
return strings.Contains(lower, "failure") ||
|
||
strings.Contains(lower, "fault") ||
|
||
strings.Contains(lower, "warning") ||
|
||
strings.Contains(lower, "predictive") ||
|
||
strings.Contains(lower, "absent") ||
|
||
strings.Contains(lower, "ac lost")
|
||
}
|
||
var issues []string
|
||
for name, afterReading := range after {
|
||
if !isFault(afterReading) {
|
||
continue
|
||
}
|
||
if beforeReading, had := before[name]; had && isFault(beforeReading) {
|
||
continue // pre-existing fault, not caused by this test
|
||
}
|
||
if prev, had := before[name]; had {
|
||
issues = append(issues, fmt.Sprintf("%s: changed from %q to %q during test", name, prev, afterReading))
|
||
} else {
|
||
issues = append(issues, fmt.Sprintf("%s: %s (appeared after test start)", name, afterReading))
|
||
}
|
||
}
|
||
sort.Strings(issues)
|
||
return issues
|
||
}
|
||
|
||
// sdrPowerSnapshot holds per-source power sums from a single `ipmitool sdr` read.
|
||
type sdrPowerSnapshot struct {
|
||
PSUInW float64 // sum of PSU AC input across all slots
|
||
PSUOutW float64 // sum of PSU DC output across all slots
|
||
GPUSlotW float64 // sum of GPU slot/GPU power sensors
|
||
|
||
// Per-slot PSU data from collector.PSUSlotsFromSDR — same slot keys as
|
||
// audit HardwarePowerSupply.Slot (0-based strings).
|
||
PSUSlots map[string]BenchmarkPSUSlotPower
|
||
|
||
SkippedSensors []string // sensors rejected during self-healing
|
||
}
|
||
|
||
type benchmarkSDRSeriesSummary struct {
|
||
PSUInW float64
|
||
PSUOutW float64
|
||
GPUSlotW float64
|
||
PSUSlots map[string]BenchmarkPSUSlotPower
|
||
Samples int
|
||
|
||
SkippedSensors []string
|
||
}
|
||
|
||
// sdrSensor is a name+watts pair used for GPU slot self-healing filtering.
|
||
type sdrSensor struct {
|
||
name string
|
||
watts float64
|
||
}
|
||
|
||
// filterSensorGroup removes physically implausible readings from a group.
|
||
// Hard bounds: 0 < watts ≤ maxPerSensorW. Within groups of 2+ sensors,
|
||
// values more than 5× the group median are rejected as stuck/fault sensors.
|
||
func filterSensorGroup(sensors []sdrSensor, maxPerSensorW float64) (valid []sdrSensor, skipped []string) {
|
||
var inBounds []sdrSensor
|
||
for _, s := range sensors {
|
||
if s.watts <= 0 || s.watts > maxPerSensorW {
|
||
skipped = append(skipped, fmt.Sprintf("%s (%.0f W: out of range 0–%.0f W)", s.name, s.watts, maxPerSensorW))
|
||
} else {
|
||
inBounds = append(inBounds, s)
|
||
}
|
||
}
|
||
if len(inBounds) < 2 {
|
||
return inBounds, skipped
|
||
}
|
||
vals := make([]float64, len(inBounds))
|
||
for i, s := range inBounds {
|
||
vals[i] = s.watts
|
||
}
|
||
sort.Float64s(vals)
|
||
mid := len(vals) / 2
|
||
var median float64
|
||
if len(vals)%2 == 0 {
|
||
median = (vals[mid-1] + vals[mid]) / 2
|
||
} else {
|
||
median = vals[mid]
|
||
}
|
||
for _, s := range inBounds {
|
||
if median > 0 && s.watts > median*5 {
|
||
skipped = append(skipped, fmt.Sprintf("%s (%.0f W: >5× median %.0f W, likely sensor fault)", s.name, s.watts, median))
|
||
} else {
|
||
valid = append(valid, s)
|
||
}
|
||
}
|
||
return valid, skipped
|
||
}
|
||
|
||
// sampleIPMISDRPowerSensors reads power sensors from `ipmitool sdr` in a single
|
||
// invocation and returns self-healed grouped sums.
|
||
//
|
||
// PSU identification delegates to collector.PSUSlotsFromSDR which uses the same
|
||
// slot-detection regexes as the hardware audit (PSU1_POWER_IN, PSU1_PIN, PS1 POut,
|
||
// Power1…). Self-healing: bounds checking + 5× median outlier rejection.
|
||
//
|
||
// GPU slot sensors (GPU_POWER_SLOTx, GPU1 Power, …) are classified separately
|
||
// since the audit collector does not track GPU PCIe slot power.
|
||
func sampleIPMISDRPowerSensors() sdrPowerSnapshot {
|
||
// Reads from the shared hardware telemetry cache (hwSnapshot) rather than
|
||
// shelling out to `ipmitool sdr` itself — see hw_telemetry.go for why.
|
||
sdrStr := hwSnapshot().Raw
|
||
if sdrStr == "" {
|
||
return sdrPowerSnapshot{}
|
||
}
|
||
var snap sdrPowerSnapshot
|
||
|
||
// ── PSU data via audit collector ─────────────────────────────────────────
|
||
// collector.PSUSlotsFromSDR handles all vendor naming variants and applies
|
||
// bounds checking inside parseBoundedFloat (0 < w ≤ 6000 W).
|
||
collectorSlots := collector.PSUSlotsFromSDR(sdrStr)
|
||
|
||
// Convert to benchmark type and apply cross-slot median filtering.
|
||
var psuInSensors, psuOutSensors []sdrSensor
|
||
for slotKey, sp := range collectorSlots {
|
||
bsp := BenchmarkPSUSlotPower{Status: sp.Status}
|
||
if sp.InputW != nil {
|
||
bsp.InputW = sp.InputW
|
||
psuInSensors = append(psuInSensors, sdrSensor{name: "PSU-slot-" + slotKey, watts: *sp.InputW})
|
||
}
|
||
if sp.OutputW != nil {
|
||
bsp.OutputW = sp.OutputW
|
||
psuOutSensors = append(psuOutSensors, sdrSensor{name: "PSU-slot-" + slotKey + "-out", watts: *sp.OutputW})
|
||
}
|
||
if snap.PSUSlots == nil {
|
||
snap.PSUSlots = make(map[string]BenchmarkPSUSlotPower)
|
||
}
|
||
snap.PSUSlots[slotKey] = bsp
|
||
}
|
||
|
||
// Apply cross-slot outlier filter and sum.
|
||
validIn, skIn := filterSensorGroup(psuInSensors, 6000)
|
||
for _, s := range validIn {
|
||
snap.PSUInW += s.watts
|
||
}
|
||
snap.SkippedSensors = append(snap.SkippedSensors, skIn...)
|
||
|
||
validOut, skOut := filterSensorGroup(psuOutSensors, 6000)
|
||
for _, s := range validOut {
|
||
snap.PSUOutW += s.watts
|
||
}
|
||
snap.SkippedSensors = append(snap.SkippedSensors, skOut...)
|
||
|
||
// ── GPU slot sensors ─────────────────────────────────────────────────────
|
||
// collector does not track GPU PCIe slot power; classify here.
|
||
// Matches: GPU_POWER_SLOTx (MSI), GPU1 Power (xFusion), GPU_PWR_x (generic).
|
||
var gpuSensors []sdrSensor
|
||
for _, line := range strings.Split(sdrStr, "\n") {
|
||
parts := strings.Split(line, "|")
|
||
if len(parts) < 2 {
|
||
continue
|
||
}
|
||
name := strings.TrimSpace(parts[0])
|
||
nameLower := strings.ToLower(name)
|
||
if !strings.Contains(nameLower, "gpu") {
|
||
continue
|
||
}
|
||
if !strings.Contains(nameLower, "slot") && !strings.Contains(nameLower, "power") &&
|
||
!strings.Contains(nameLower, "pwr") {
|
||
continue
|
||
}
|
||
var w float64
|
||
if n, _ := fmt.Sscanf(strings.TrimSpace(parts[1]), "%f Watts", &w); n != 1 {
|
||
continue
|
||
}
|
||
gpuSensors = append(gpuSensors, sdrSensor{name: name, watts: w})
|
||
}
|
||
validGPU, skGPU := filterSensorGroup(gpuSensors, 2000)
|
||
for _, s := range validGPU {
|
||
snap.GPUSlotW += s.watts
|
||
}
|
||
snap.SkippedSensors = append(snap.SkippedSensors, skGPU...)
|
||
|
||
return snap
|
||
}
|
||
|
||
func startIPMISDRSampler(stopCh <-chan struct{}, intervalSec int) <-chan []sdrPowerSnapshot {
|
||
if intervalSec <= 0 {
|
||
intervalSec = benchmarkPowerAutotuneSampleInterval
|
||
}
|
||
ch := make(chan []sdrPowerSnapshot, 1)
|
||
go func() {
|
||
defer close(ch)
|
||
var samples []sdrPowerSnapshot
|
||
record := func() {
|
||
snap := sampleIPMISDRPowerSensors()
|
||
if snap.PSUInW <= 0 && snap.PSUOutW <= 0 && snap.GPUSlotW <= 0 && len(snap.PSUSlots) == 0 {
|
||
return
|
||
}
|
||
samples = append(samples, snap)
|
||
}
|
||
record()
|
||
ticker := time.NewTicker(time.Duration(intervalSec) * time.Second)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-stopCh:
|
||
ch <- samples
|
||
return
|
||
case <-ticker.C:
|
||
record()
|
||
}
|
||
}
|
||
}()
|
||
return ch
|
||
}
|
||
|
||
func summarizeSDRPowerSeries(samples []sdrPowerSnapshot) benchmarkSDRSeriesSummary {
|
||
var summary benchmarkSDRSeriesSummary
|
||
if len(samples) == 0 {
|
||
return summary
|
||
}
|
||
|
||
type slotAggregate struct {
|
||
inputs []float64
|
||
outputs []float64
|
||
status string
|
||
}
|
||
|
||
slotAgg := make(map[string]*slotAggregate)
|
||
skippedSet := make(map[string]struct{})
|
||
var inputTotals []float64
|
||
var outputTotals []float64
|
||
var gpuSlotTotals []float64
|
||
|
||
for _, sample := range samples {
|
||
if sample.PSUInW > 0 {
|
||
inputTotals = append(inputTotals, sample.PSUInW)
|
||
}
|
||
if sample.PSUOutW > 0 {
|
||
outputTotals = append(outputTotals, sample.PSUOutW)
|
||
}
|
||
if sample.GPUSlotW > 0 {
|
||
gpuSlotTotals = append(gpuSlotTotals, sample.GPUSlotW)
|
||
}
|
||
for _, skipped := range sample.SkippedSensors {
|
||
if skipped != "" {
|
||
skippedSet[skipped] = struct{}{}
|
||
}
|
||
}
|
||
for slot, reading := range sample.PSUSlots {
|
||
agg := slotAgg[slot]
|
||
if agg == nil {
|
||
agg = &slotAggregate{}
|
||
slotAgg[slot] = agg
|
||
}
|
||
if reading.InputW != nil && *reading.InputW > 0 {
|
||
agg.inputs = append(agg.inputs, *reading.InputW)
|
||
}
|
||
if reading.OutputW != nil && *reading.OutputW > 0 {
|
||
agg.outputs = append(agg.outputs, *reading.OutputW)
|
||
}
|
||
switch {
|
||
case reading.Status == "":
|
||
case agg.status == "":
|
||
agg.status = reading.Status
|
||
case agg.status == "OK" && reading.Status != "OK":
|
||
agg.status = reading.Status
|
||
}
|
||
}
|
||
}
|
||
|
||
summary.PSUInW = benchmarkMean(inputTotals)
|
||
summary.PSUOutW = benchmarkMean(outputTotals)
|
||
summary.GPUSlotW = benchmarkMean(gpuSlotTotals)
|
||
summary.Samples = len(samples)
|
||
|
||
if len(slotAgg) > 0 {
|
||
summary.PSUSlots = make(map[string]BenchmarkPSUSlotPower, len(slotAgg))
|
||
for slot, agg := range slotAgg {
|
||
reading := BenchmarkPSUSlotPower{Status: agg.status}
|
||
if mean := benchmarkMean(agg.inputs); mean > 0 {
|
||
v := mean
|
||
reading.InputW = &v
|
||
}
|
||
if mean := benchmarkMean(agg.outputs); mean > 0 {
|
||
v := mean
|
||
reading.OutputW = &v
|
||
}
|
||
summary.PSUSlots[slot] = reading
|
||
}
|
||
}
|
||
if len(skippedSet) > 0 {
|
||
summary.SkippedSensors = make([]string, 0, len(skippedSet))
|
||
for skipped := range skippedSet {
|
||
summary.SkippedSensors = append(summary.SkippedSensors, skipped)
|
||
}
|
||
sort.Strings(summary.SkippedSensors)
|
||
}
|
||
|
||
return summary
|
||
}
|
||
|
||
// queryIPMIServerPowerW reads the current server power draw from the shared
|
||
// hardware telemetry cache (hwSnapshot), which runs `ipmitool dcmi power
|
||
// reading` itself on its own cadence — see hw_telemetry.go for why this
|
||
// doesn't shell out directly. Returns 0 and an error if IPMI dcmi power is
|
||
// unavailable on this BMC.
|
||
func queryIPMIServerPowerW() (float64, error) {
|
||
if w := hwSnapshot().DCMIPowerW; w > 0 {
|
||
return w, nil
|
||
}
|
||
return 0, fmt.Errorf("ipmitool dcmi power reading unavailable")
|
||
}
|
||
|
||
// characterizeServerPower computes BenchmarkServerPower from idle and loaded
|
||
// samples plus the GPU-reported average power during steady state.
|
||
func characterizeServerPower(idleW, loadedW, gpuReportedSumW float64, source string, available bool) *BenchmarkServerPower {
|
||
sp := &BenchmarkServerPower{
|
||
Available: available,
|
||
Source: normalizeBenchmarkPowerSource(source),
|
||
SampleIntervalSec: benchmarkPowerAutotuneSampleInterval,
|
||
}
|
||
if !available {
|
||
sp.Notes = append(sp.Notes, "IPMI power reading unavailable; server-side power characterization skipped")
|
||
return sp
|
||
}
|
||
sp.IdleW = idleW
|
||
sp.LoadedW = loadedW
|
||
sp.DeltaW = loadedW - idleW
|
||
sp.GPUReportedSumW = gpuReportedSumW
|
||
if gpuReportedSumW > 0 && sp.DeltaW > 0 {
|
||
sp.ReportingRatio = sp.DeltaW / gpuReportedSumW
|
||
}
|
||
return sp
|
||
}
|
||
|
||
// readServerModel returns the DMI system product name (e.g. "SuperMicro SYS-421GE-TNRT").
|
||
// Returns empty string if unavailable (non-Linux or missing DMI entry).
|
||
func readServerModel() string {
|
||
data, err := os.ReadFile("/sys/class/dmi/id/product_name")
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(string(data))
|
||
}
|
||
|
||
// filterRowsByGPU returns only the metric rows for a specific GPU index.
|
||
func filterRowsByGPU(rows []GPUMetricRow, gpuIndex int) []GPUMetricRow {
|
||
var out []GPUMetricRow
|
||
for _, r := range rows {
|
||
if r.GPUIndex == gpuIndex {
|
||
out = append(out, r)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// parseBenchmarkBurnLogByGPU splits a multi-GPU bee-gpu-burn output by [gpu N] prefix
|
||
// and returns a per-GPU parse result map.
|
||
func parseBenchmarkBurnLogByGPU(raw string) map[int]benchmarkBurnParseResult {
|
||
gpuLines := make(map[int][]string)
|
||
for _, line := range strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if !strings.HasPrefix(line, "[gpu ") {
|
||
continue
|
||
}
|
||
end := strings.Index(line, "] ")
|
||
if end < 0 {
|
||
continue
|
||
}
|
||
gpuIdx, err := strconv.Atoi(strings.TrimSpace(line[5:end]))
|
||
if err != nil {
|
||
continue
|
||
}
|
||
gpuLines[gpuIdx] = append(gpuLines[gpuIdx], line[end+2:])
|
||
}
|
||
results := make(map[int]benchmarkBurnParseResult, len(gpuLines))
|
||
for gpuIdx, lines := range gpuLines {
|
||
// Lines are already stripped of the [gpu N] prefix; parseBenchmarkBurnLog
|
||
// calls stripBenchmarkPrefix which is a no-op on already-stripped lines.
|
||
results[gpuIdx] = parseBenchmarkBurnLog(strings.Join(lines, "\n"))
|
||
}
|
||
return results
|
||
}
|
||
|
||
// runNvidiaBenchmarkParallel runs warmup and steady compute on all selected GPUs
|
||
// simultaneously using a single bee-gpu-burn invocation per phase.
|
||
func runNvidiaBenchmarkParallel(
|
||
ctx context.Context,
|
||
verboseLog, runDir string,
|
||
selected []int,
|
||
infoByIndex map[int]benchmarkGPUInfo,
|
||
opts NvidiaBenchmarkOptions,
|
||
spec benchmarkProfileSpec,
|
||
logFunc func(string),
|
||
result *NvidiaBenchmarkResult,
|
||
calibByIndex map[int]benchmarkPowerCalibrationResult,
|
||
serverIdleW *float64, serverLoadedWSum *float64,
|
||
serverIdleOK *bool, serverLoadedOK *bool, serverLoadedSamples *int,
|
||
allMetricRows *[]GPUMetricRow,
|
||
metricTimelineSec *float64,
|
||
gpuBurnLog string,
|
||
) {
|
||
allDevices := joinIndexList(selected)
|
||
|
||
// Build per-GPU result stubs.
|
||
gpuResults := make(map[int]*BenchmarkGPUResult, len(selected))
|
||
for _, idx := range selected {
|
||
r := &BenchmarkGPUResult{Index: idx, Status: "FAILED"}
|
||
if info, ok := infoByIndex[idx]; ok {
|
||
populateBenchmarkGPUInfo(r, info)
|
||
}
|
||
if calib, ok := calibByIndex[idx]; ok {
|
||
r.CalibratedPeakPowerW = calib.Summary.P95PowerW
|
||
r.CalibratedPeakTempC = calib.Summary.P95TempC
|
||
r.PowerCalibrationTries = calib.Attempts
|
||
r.PowerLimitDerated = calib.Derated
|
||
r.Notes = append(r.Notes, calib.Notes...)
|
||
if calib.CoolingWarning != "" {
|
||
r.CoolingWarning = calib.CoolingWarning
|
||
}
|
||
}
|
||
if norm := findBenchmarkNormalization(result.Normalization.GPUs, idx); norm != nil {
|
||
r.LockedGraphicsClockMHz = norm.GPUClockLockMHz
|
||
r.LockedMemoryClockMHz = norm.MemoryClockLockMHz
|
||
}
|
||
gpuResults[idx] = r
|
||
}
|
||
|
||
// Baseline: sample all GPUs together.
|
||
baselineRows, err := collectBenchmarkSamples(ctx, spec.BaselineSec, selected)
|
||
if err != nil && err != context.Canceled {
|
||
for _, idx := range selected {
|
||
gpuResults[idx].Notes = append(gpuResults[idx].Notes, "baseline sampling failed: "+err.Error())
|
||
}
|
||
}
|
||
for _, idx := range selected {
|
||
perGPU := filterRowsByGPU(baselineRows, idx)
|
||
gpuResults[idx].Baseline = summarizeBenchmarkTelemetry(perGPU)
|
||
}
|
||
appendBenchmarkMetrics(allMetricRows, baselineRows, "baseline", metricTimelineSec, float64(spec.BaselineSec))
|
||
|
||
// Sample server idle power once.
|
||
if !*serverIdleOK {
|
||
if w, ok := sampleBenchmarkPowerSourceSeries(ctx, opts.ServerPowerSource, maxInt(spec.BaselineSec, 10), benchmarkPowerAutotuneSampleInterval); ok {
|
||
*serverIdleW = w
|
||
*serverIdleOK = true
|
||
logFunc(fmt.Sprintf("server idle power (%s): %.0f W", opts.ServerPowerSource, w))
|
||
}
|
||
}
|
||
|
||
// Warmup: all GPUs simultaneously.
|
||
warmupCmd := []string{
|
||
"bee-gpu-burn",
|
||
"--seconds", strconv.Itoa(spec.WarmupSec),
|
||
"--size-mb", strconv.Itoa(opts.SizeMB),
|
||
"--devices", allDevices,
|
||
}
|
||
logFunc(fmt.Sprintf("GPUs %s: parallel warmup (%ds)", allDevices, spec.WarmupSec))
|
||
warmupOut, warmupRows, warmupErr := runBenchmarkCommandWithMetrics(ctx, verboseLog, "gpu-all-warmup.log", warmupCmd, nil, selected, logFunc)
|
||
appendBenchmarkMetrics(allMetricRows, warmupRows, "warmup", metricTimelineSec, float64(spec.WarmupSec))
|
||
appendBenchmarkStageLog(gpuBurnLog, "bee-gpu-burn", "warmup", warmupOut)
|
||
if warmupErr != nil {
|
||
for _, idx := range selected {
|
||
gpuResults[idx].Notes = append(gpuResults[idx].Notes, "parallel warmup failed: "+warmupErr.Error())
|
||
}
|
||
}
|
||
warmupParseByGPU := parseBenchmarkBurnLogByGPU(string(warmupOut))
|
||
supportedPrecisions := append([]string(nil), benchmarkPrecisionPhases...)
|
||
for _, idx := range selected {
|
||
if pr, ok := warmupParseByGPU[idx]; ok && pr.ComputeCapability != "" {
|
||
if gpuResults[idx].ComputeCapability == "" {
|
||
gpuResults[idx].ComputeCapability = pr.ComputeCapability
|
||
}
|
||
if ccPrecisions := benchmarkSupportedPrecisions(pr.ComputeCapability); len(ccPrecisions) < len(supportedPrecisions) {
|
||
supportedPrecisions = ccPrecisions
|
||
}
|
||
}
|
||
}
|
||
|
||
// Run synthetic precision phases and the combined steady phase as one
|
||
// uninterrupted command so the GPUs stay hot between windows.
|
||
eccBase := make(map[int]BenchmarkECCCounters, len(selected))
|
||
for _, idx := range selected {
|
||
eccBase[idx], _ = queryECCCounters(idx)
|
||
}
|
||
planLabels, planPhases, basePhaseSec, mixedPhaseSec := buildBenchmarkSteadyPlan(spec, supportedPrecisions, func(label string) string {
|
||
if label == "mixed" {
|
||
return "steady"
|
||
}
|
||
return "gpu-all-steady-" + label
|
||
})
|
||
planCmd := []string{
|
||
"bee-gpu-burn",
|
||
"--seconds", strconv.Itoa(basePhaseSec),
|
||
"--size-mb", strconv.Itoa(opts.SizeMB),
|
||
"--devices", allDevices,
|
||
"--precision-plan", strings.Join(planLabels, ","),
|
||
"--precision-plan-seconds", benchmarkPlanDurationsCSV(planPhases),
|
||
}
|
||
logFunc(fmt.Sprintf("GPUs %s: uninterrupted precision plan (%d precision phases x %ds, mixed %ds)", allDevices, len(supportedPrecisions), basePhaseSec, mixedPhaseSec))
|
||
serverPowerStopCh := make(chan struct{})
|
||
serverPowerCh := startSelectedPowerSourceSampler(serverPowerStopCh, opts.ServerPowerSource, benchmarkPowerAutotuneSampleInterval)
|
||
_, phaseRowsByStage, phaseLogs, planErr := runBenchmarkPlannedCommandWithMetrics(ctx, verboseLog, "gpu-all-precision-plan.log", planCmd, nil, selected, planPhases, logFunc)
|
||
close(serverPowerStopCh)
|
||
if serverPowerSamples := <-serverPowerCh; len(serverPowerSamples) > 0 {
|
||
*serverLoadedWSum += benchmarkMean(serverPowerSamples)
|
||
(*serverLoadedSamples)++
|
||
*serverLoadedOK = true
|
||
logFunc(fmt.Sprintf("GPUs %s: server loaded power (%s avg): %.0f W", allDevices, opts.ServerPowerSource, benchmarkMean(serverPowerSamples)))
|
||
}
|
||
for _, phaseSpec := range planPhases {
|
||
if rows := phaseRowsByStage[phaseSpec.MetricStage]; len(rows) > 0 {
|
||
appendBenchmarkMetrics(allMetricRows, rows, phaseSpec.MetricStage, metricTimelineSec, float64(phaseSpec.DurationSec))
|
||
}
|
||
appendBenchmarkStageLog(gpuBurnLog, "bee-gpu-burn", phaseSpec.MetricStage, phaseLogs[phaseSpec.PlanLabel])
|
||
}
|
||
for _, prec := range supportedPrecisions {
|
||
phaseLogName := "gpu-all-steady-" + prec
|
||
phaseRows := phaseRowsByStage[phaseLogName]
|
||
parseByGPU := parseBenchmarkBurnLogByGPU(string(phaseLogs[prec]))
|
||
for _, idx := range selected {
|
||
perGPU := filterRowsByGPU(phaseRows, idx)
|
||
phase := BenchmarkPrecisionSteadyPhase{
|
||
Precision: prec,
|
||
Status: "OK",
|
||
Steady: summarizeBenchmarkTelemetry(perGPU),
|
||
}
|
||
if status, note := benchmarkPlannedPhaseStatus(phaseLogs[prec]); status != "OK" {
|
||
phase.Status = status
|
||
phase.Notes = note
|
||
gpuResults[idx].PrecisionFailures = append(gpuResults[idx].PrecisionFailures, prec+":"+status)
|
||
}
|
||
if pr, ok := parseByGPU[idx]; ok {
|
||
for _, p := range pr.Profiles {
|
||
if p.Supported {
|
||
phase.TeraOpsPerSec += p.TeraOpsPerSec
|
||
phase.WeightedTeraOpsPerSec += p.WeightedTeraOpsPerSec
|
||
}
|
||
}
|
||
}
|
||
gpuResults[idx].PrecisionSteady = append(gpuResults[idx].PrecisionSteady, phase)
|
||
}
|
||
}
|
||
|
||
// Snapshot throttle counters before steady.
|
||
beforeThrottle := make(map[int]BenchmarkThrottleCounters, len(selected))
|
||
for _, idx := range selected {
|
||
beforeThrottle[idx], _ = queryThrottleCounters(idx)
|
||
}
|
||
|
||
logFunc(fmt.Sprintf("GPUs %s: parallel steady compute (combined, %ds)", allDevices, mixedPhaseSec))
|
||
afterThrottle := make(map[int]BenchmarkThrottleCounters, len(selected))
|
||
for _, idx := range selected {
|
||
afterThrottle[idx], _ = queryThrottleCounters(idx)
|
||
}
|
||
|
||
steadyRows := phaseRowsByStage["steady"]
|
||
parseResults := parseBenchmarkBurnLogByGPU(string(phaseLogs["mixed"]))
|
||
|
||
for _, idx := range selected {
|
||
perGPU := filterRowsByGPU(steadyRows, idx)
|
||
gpuResults[idx].Steady = summarizeBenchmarkTelemetry(perGPU)
|
||
gpuResults[idx].Throttle = diffThrottleCounters(beforeThrottle[idx], afterThrottle[idx])
|
||
if eccFinal, err := queryECCCounters(idx); err == nil {
|
||
gpuResults[idx].ECC = diffECCCounters(eccBase[idx], eccFinal)
|
||
}
|
||
|
||
if pr, ok := parseResults[idx]; ok {
|
||
gpuResults[idx].ComputeCapability = pr.ComputeCapability
|
||
gpuResults[idx].Backend = pr.Backend
|
||
gpuResults[idx].PrecisionResults = pr.Profiles
|
||
if pr.Fallback {
|
||
gpuResults[idx].Notes = append(gpuResults[idx].Notes, "benchmark used driver PTX fallback; tensor throughput score is not comparable")
|
||
}
|
||
}
|
||
if planErr != nil {
|
||
gpuResults[idx].Notes = append(gpuResults[idx].Notes, "precision plan failed: "+planErr.Error())
|
||
}
|
||
}
|
||
|
||
// Cooldown: all GPUs together.
|
||
if spec.CooldownSec > 0 {
|
||
cooldownRows, err := collectBenchmarkSamples(ctx, spec.CooldownSec, selected)
|
||
if err != nil && err != context.Canceled {
|
||
for _, idx := range selected {
|
||
gpuResults[idx].Notes = append(gpuResults[idx].Notes, "cooldown sampling failed: "+err.Error())
|
||
}
|
||
}
|
||
for _, idx := range selected {
|
||
perGPU := filterRowsByGPU(cooldownRows, idx)
|
||
gpuResults[idx].Cooldown = summarizeBenchmarkTelemetry(perGPU)
|
||
}
|
||
appendBenchmarkMetrics(allMetricRows, cooldownRows, "cooldown", metricTimelineSec, float64(spec.CooldownSec))
|
||
}
|
||
|
||
// Score and finalize each GPU.
|
||
for _, idx := range selected {
|
||
r := gpuResults[idx]
|
||
applyBenchmarkSteadyFallback(r)
|
||
r.Scores = scoreBenchmarkGPUResult(*r)
|
||
r.DegradationReasons = detectBenchmarkDegradationReasons(*r, result.Normalization.Status)
|
||
pr := parseResults[idx]
|
||
switch {
|
||
case planErr != nil:
|
||
r.Status = classifySATErrorStatus(phaseLogs["mixed"], planErr)
|
||
case len(r.PrecisionFailures) > 0:
|
||
r.Status = "PARTIAL"
|
||
case pr.Fallback:
|
||
r.Status = "PARTIAL"
|
||
default:
|
||
r.Status = "OK"
|
||
}
|
||
result.GPUs = append(result.GPUs, finalizeBenchmarkGPUResult(*r))
|
||
}
|
||
}
|
||
|
||
func populateBenchmarkGPUInfo(result *BenchmarkGPUResult, info benchmarkGPUInfo) {
|
||
result.UUID = info.UUID
|
||
result.Name = info.Name
|
||
result.BusID = info.BusID
|
||
result.VBIOS = info.VBIOS
|
||
result.PowerLimitW = info.PowerLimitW
|
||
result.MultiprocessorCount = info.MultiprocessorCount
|
||
result.DefaultPowerLimitW = info.DefaultPowerLimitW
|
||
result.ShutdownTempC = info.ShutdownTempC
|
||
result.SlowdownTempC = info.SlowdownTempC
|
||
result.MaxGraphicsClockMHz = info.MaxGraphicsClockMHz
|
||
result.BaseGraphicsClockMHz = info.BaseGraphicsClockMHz
|
||
result.MaxMemoryClockMHz = info.MaxMemoryClockMHz
|
||
}
|
||
|
||
// readBenchmarkHostConfig reads static CPU and memory configuration from
|
||
// /proc/cpuinfo and /proc/meminfo. Returns nil if neither source is readable.
|
||
func readBenchmarkHostConfig() *BenchmarkHostConfig {
|
||
cfg := &BenchmarkHostConfig{}
|
||
populated := false
|
||
|
||
// Parse /proc/cpuinfo for CPU model, sockets, cores, threads.
|
||
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
|
||
socketIDs := map[string]struct{}{}
|
||
coresPerSocket := map[string]int{}
|
||
var modelName string
|
||
threads := 0
|
||
for _, line := range strings.Split(string(data), "\n") {
|
||
kv := strings.SplitN(line, ":", 2)
|
||
if len(kv) != 2 {
|
||
continue
|
||
}
|
||
key := strings.TrimSpace(kv[0])
|
||
val := strings.TrimSpace(kv[1])
|
||
switch key {
|
||
case "processor":
|
||
threads++
|
||
case "model name":
|
||
if modelName == "" {
|
||
modelName = val
|
||
}
|
||
case "physical id":
|
||
socketIDs[val] = struct{}{}
|
||
case "cpu cores":
|
||
// Overwrite per-socket core count (last wins per socket, but all
|
||
// entries for the same socket report the same value).
|
||
if physLine := ""; physLine == "" {
|
||
// We accumulate below by treating cpu cores as a per-thread
|
||
// field; sum by socket requires a two-pass approach. Use the
|
||
// simpler approximation: totalCores = threads / (threads per core).
|
||
_ = val
|
||
}
|
||
}
|
||
}
|
||
// Second pass: per-socket core count.
|
||
var curSocket string
|
||
for _, line := range strings.Split(string(data), "\n") {
|
||
kv := strings.SplitN(line, ":", 2)
|
||
if len(kv) != 2 {
|
||
continue
|
||
}
|
||
key := strings.TrimSpace(kv[0])
|
||
val := strings.TrimSpace(kv[1])
|
||
switch key {
|
||
case "physical id":
|
||
curSocket = val
|
||
case "cpu cores":
|
||
if curSocket != "" {
|
||
if _, seen := coresPerSocket[curSocket]; !seen {
|
||
v, _ := strconv.Atoi(val)
|
||
coresPerSocket[curSocket] = v
|
||
}
|
||
}
|
||
}
|
||
}
|
||
totalCores := 0
|
||
for _, c := range coresPerSocket {
|
||
totalCores += c
|
||
}
|
||
cfg.CPUModel = modelName
|
||
cfg.CPUSockets = len(socketIDs)
|
||
if cfg.CPUSockets == 0 && threads > 0 {
|
||
cfg.CPUSockets = 1
|
||
}
|
||
cfg.CPUCores = totalCores
|
||
cfg.CPUThreads = threads
|
||
if modelName != "" || threads > 0 {
|
||
populated = true
|
||
}
|
||
}
|
||
|
||
// Parse /proc/meminfo for total physical RAM.
|
||
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
|
||
for _, line := range strings.Split(string(data), "\n") {
|
||
if strings.HasPrefix(line, "MemTotal:") {
|
||
fields := strings.Fields(line)
|
||
if len(fields) >= 2 {
|
||
kb, _ := strconv.ParseUint(fields[1], 10, 64)
|
||
cfg.MemTotalGiB = float64(kb) / (1024 * 1024)
|
||
populated = true
|
||
}
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
if !populated {
|
||
return nil
|
||
}
|
||
return cfg
|
||
}
|
||
|
||
// startCPULoadSampler starts a goroutine that samples host CPU load every
|
||
// intervalSec seconds until stopCh is closed, then sends the collected
|
||
// samples on the returned channel.
|
||
func startCPULoadSampler(stopCh <-chan struct{}, intervalSec int) <-chan []float64 {
|
||
ch := make(chan []float64, 1)
|
||
go func() {
|
||
var samples []float64
|
||
ticker := time.NewTicker(time.Duration(intervalSec) * time.Second)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-stopCh:
|
||
ch <- samples
|
||
return
|
||
case <-ticker.C:
|
||
if pct := sampleCPULoadPct(); pct > 0 {
|
||
samples = append(samples, pct)
|
||
}
|
||
}
|
||
}
|
||
}()
|
||
return ch
|
||
}
|
||
|
||
// summarizeCPULoad computes stats over sampled CPU load values and assigns
|
||
// a health status.
|
||
func summarizeCPULoad(samples []float64) *BenchmarkCPULoad {
|
||
if len(samples) == 0 {
|
||
return nil
|
||
}
|
||
sorted := append([]float64(nil), samples...)
|
||
sort.Float64s(sorted)
|
||
var sum float64
|
||
for _, v := range sorted {
|
||
sum += v
|
||
}
|
||
avg := sum / float64(len(sorted))
|
||
p95 := sorted[int(float64(len(sorted))*0.95)]
|
||
max := sorted[len(sorted)-1]
|
||
|
||
cl := &BenchmarkCPULoad{
|
||
AvgPct: math.Round(avg*10) / 10,
|
||
MaxPct: math.Round(max*10) / 10,
|
||
P95Pct: math.Round(p95*10) / 10,
|
||
Samples: len(sorted),
|
||
}
|
||
|
||
// Compute standard deviation to detect instability.
|
||
var variance float64
|
||
for _, v := range sorted {
|
||
d := v - avg
|
||
variance += d * d
|
||
}
|
||
stdDev := math.Sqrt(variance / float64(len(sorted)))
|
||
|
||
switch {
|
||
case avg > 20 || max > 40:
|
||
cl.Status = "high"
|
||
cl.Note = fmt.Sprintf("avg %.1f%% max %.1f%% — elevated host CPU load may interfere with GPU benchmark results", avg, max)
|
||
case stdDev > 12:
|
||
cl.Status = "unstable"
|
||
cl.Note = fmt.Sprintf("avg %.1f%% stddev %.1f%% — host CPU load was erratic during the benchmark", avg, stdDev)
|
||
default:
|
||
cl.Status = "ok"
|
||
}
|
||
return cl
|
||
}
|
||
|
||
// runBenchmarkPowerCalibration runs the configured power-fit load for the supplied
|
||
// GPU set and actively watches throttle counters. seedLimits, when provided, are treated as
|
||
// the starting point for this calibration pass rather than as immutable fixed
|
||
// limits. This matters during cumulative ramp-up: once an additional GPU is
|
||
// introduced, every already-active GPU must be revalidated under the new
|
||
// thermal state instead of assuming its previous single-step limit is still
|
||
// valid. The selected reduced power limits stay active for the main benchmark
|
||
// and are restored by the caller afterwards.
|