fix(sat): fan check uses hottest GPU load + IPMI-hang-proof polling
- GPU load switches from bee-gpu-burn (compute burn, ~88% TDP) to dcgmproftester -t 1004 / targeted_power via resolveBenchmarkPowerLoadCommand — the same engine Power/Thermal Fit uses and the hottest sustained NVIDIA load we have, so fans are actually pushed toward their ceiling. - Sample loop is now IPMI-hang-proof: every ipmitool read is time-boxed in an abandonable goroutine, and the poll interval backs off geometrically (1s→30s) when reads are slow, tightening again on recovery. A plateau is only trusted while telemetry is healthy; degraded runs ride out to MaxLoadSec. Summary gains fan_samples / telemetry_degraded. Drops the per-second nvidia-smi+power+cpu-temp sampling from the hot loop. - Dead code removed: FanStressRow, GPUStressMetric, sampleFanStressRow, sampleGPUStressMetrics, WriteFanStressCSV/WriteFanSensorsCSV, analyzeMaxTemp, sampleSystemPowerResolved. Topology fan tiles: - size encodes the fan's ceiling RPM (its class), not current speed; coloured fill rising from the bottom encodes live duty cycle (current / ceiling), shown only when the ceiling was measured. - glyph spin rate now maps absolute RPM into a human-perceptible band (fanSpinPeriodSec: 2.2s/turn at <=1000 RPM, 0.35s at >=13000). - the "N fans · N OK · tile size ∝ …" caption line is gone. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VHG21rgTUiR1G3qFHTVmN
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
bb2a501a28
commit
8cb250f3f4
@@ -12,6 +12,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -35,29 +36,6 @@ type FanReading struct {
|
||||
RPM float64
|
||||
}
|
||||
|
||||
// GPUStressMetric holds per-GPU metrics during the stress test.
|
||||
type GPUStressMetric struct {
|
||||
Index int
|
||||
TempC float64
|
||||
UsagePct float64
|
||||
PowerW float64
|
||||
ClockMHz float64
|
||||
Throttled bool // true if any throttle reason is active
|
||||
}
|
||||
|
||||
// FanStressRow is one second-interval telemetry sample covering all monitored dimensions.
|
||||
type FanStressRow struct {
|
||||
TimestampUTC string
|
||||
ElapsedSec float64
|
||||
Phase string // "baseline", "load1", "pause", "load2", "cooldown"
|
||||
GPUs []GPUStressMetric
|
||||
Fans []FanReading
|
||||
CPUMaxTempC float64 // highest CPU temperature from ipmitool / sensors
|
||||
SysPowerW float64
|
||||
SysPowerSource string
|
||||
SysPowerMode string
|
||||
}
|
||||
|
||||
type cachedPowerReading struct {
|
||||
Value float64
|
||||
Source string
|
||||
@@ -76,8 +54,6 @@ type fanPeakCandidate struct {
|
||||
}
|
||||
|
||||
var (
|
||||
systemPowerCacheMu sync.Mutex
|
||||
systemPowerCache cachedPowerReading
|
||||
fanObservationMu sync.Mutex
|
||||
fanObservation fanObservationState
|
||||
fanObservationInit bool
|
||||
@@ -157,10 +133,16 @@ func (s *System) RunFanCheck(ctx context.Context, baseDir string, opts FanCheckO
|
||||
logFunc(fmt.Sprintf("Fan check: %d fans; load = CPU/mem:%v + GPU:%s", len(baseFans), haveCPU, orNone(haveGPU, vendor)))
|
||||
|
||||
// ── Load: every source runs at the same time, each in its own goroutine.
|
||||
// Sources can take different amounts of time to actually reach full load
|
||||
// (stressapptest is near-instant; a GPU burn kernel needs to compile and
|
||||
// ramp), so the plateau clock does not start until every launched source
|
||||
// has reported its process running, plus a fixed GPU ramp grace.
|
||||
// GPU load is the hottest sustained NVIDIA load we have — dcgmproftester
|
||||
// -t 1004 / targeted_power, the same engine the Power/Thermal Fit
|
||||
// benchmark uses (resolveBenchmarkPowerLoadCommand) — not the
|
||||
// compute-throughput bee-gpu-burn, which tops out well below TDP and so
|
||||
// never demands the fans' true ceiling.
|
||||
//
|
||||
// Sources reach full load at different times (stressapptest is instant; a
|
||||
// dcgmproftester kernel compiles and ramps), so the plateau clock does not
|
||||
// start until every launched source reports its process running, plus a
|
||||
// fixed GPU ramp grace.
|
||||
loadCtx, loadCancel := context.WithTimeout(ctx, time.Duration(opts.MaxLoadSec)*time.Second)
|
||||
defer loadCancel()
|
||||
|
||||
@@ -190,14 +172,20 @@ func (s *System) RunFanCheck(ctx context.Context, baseDir string, opts FanCheckO
|
||||
loadWG.Add(1)
|
||||
go func() {
|
||||
defer loadWG.Done()
|
||||
cmd := buildGPUStressCmd(loadCtx, vendor, opts.MaxLoadSec)
|
||||
if cmd == nil {
|
||||
logFunc("GPU load unavailable (no burn tool for " + vendor + ")")
|
||||
appendSATVerboseLog(verboseLog, "gpu load: no burn tool")
|
||||
cmd, label, err := buildFanCheckGPULoadCmd(loadCtx, vendor, opts.MaxLoadSec, opts.GPUIndices)
|
||||
if err != nil || cmd == nil {
|
||||
logFunc("GPU load unavailable: " + errString(err))
|
||||
appendSATVerboseLog(verboseLog, "gpu load unavailable: "+errString(err))
|
||||
started <- false
|
||||
return
|
||||
}
|
||||
logFunc("GPU load running (" + vendor + ")")
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFunc("GPU load failed to start: " + err.Error())
|
||||
appendSATVerboseLog(verboseLog, "gpu load start error: "+err.Error())
|
||||
started <- false
|
||||
return
|
||||
}
|
||||
logFunc("GPU load running (" + label + ")")
|
||||
started <- true
|
||||
_ = cmd.Wait()
|
||||
}()
|
||||
@@ -226,19 +214,39 @@ func (s *System) RunFanCheck(ctx context.Context, baseDir string, opts FanCheckO
|
||||
appendSATVerboseLog(verboseLog, fmt.Sprintf("%d load source(s) active; plateau clock effective from +%.0fs",
|
||||
activeLoads, readyAt.Sub(start).Seconds()))
|
||||
|
||||
// ── Sample loop: one row/second, per-fan plateau tracking.
|
||||
// ── Sample loop with IPMI-hang protection.
|
||||
//
|
||||
// Under full load ipmitool over KCS can take tens of seconds per call or
|
||||
// wedge outright. So: every fan read is time-boxed (readFansBounded runs
|
||||
// it in a goroutine we abandon on timeout — a wedged KCS read can never
|
||||
// block this loop), and the polling interval backs off geometrically when
|
||||
// reads are slow and tightens again when they recover. A plateau is only
|
||||
// declared while telemetry is healthy (interval near the floor); a
|
||||
// degraded run just rides out to MaxLoadSec and records the peak it saw.
|
||||
const (
|
||||
fanPollFloor = 1 * time.Second
|
||||
fanPollCeil = 30 * time.Second
|
||||
fanReadTMO = 8 * time.Second
|
||||
)
|
||||
type fanState struct {
|
||||
peak float64
|
||||
lastRiseSec float64
|
||||
}
|
||||
fanBy := map[string]*fanState{}
|
||||
var rows []FanStressRow
|
||||
rampConfirmed := false
|
||||
plateauReached := false
|
||||
aborted := false
|
||||
degraded := false
|
||||
goodSamples := 0
|
||||
poll := fanPollFloor
|
||||
|
||||
csvPath := filepath.Join(runDir, "fan-sensors.csv")
|
||||
_ = os.WriteFile(csvPath, []byte("elapsed_sec,fan_name,rpm\n"), 0644)
|
||||
csvFile, _ := os.OpenFile(csvPath, os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if csvFile != nil {
|
||||
defer csvFile.Close()
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
@@ -247,13 +255,36 @@ loop:
|
||||
break loop
|
||||
case <-loadCtx.Done():
|
||||
break loop // MaxLoadSec reached
|
||||
case <-ticker.C:
|
||||
case <-time.After(poll):
|
||||
}
|
||||
elapsed := time.Since(start).Seconds()
|
||||
row := sampleFanStressRow(opts.GPUIndices, "load", elapsed)
|
||||
rows = append(rows, row)
|
||||
|
||||
for _, f := range row.Fans {
|
||||
readStart := time.Now()
|
||||
fans, ok := readFansBounded(fanReadTMO)
|
||||
readDur := time.Since(readStart)
|
||||
|
||||
// Adapt the interval to how ipmitool is behaving.
|
||||
switch {
|
||||
case !ok || readDur > fanReadTMO*3/4:
|
||||
if poll < fanPollCeil {
|
||||
poll = minDuration(poll*2, fanPollCeil)
|
||||
degraded = true
|
||||
appendSATVerboseLog(verboseLog, fmt.Sprintf("[%.0fs] ipmitool slow (%.1fs, ok=%v) — polling backed off to %s",
|
||||
elapsed, readDur.Seconds(), ok, poll))
|
||||
logFunc(fmt.Sprintf("IPMI slow — fan polling backed off to %s", poll))
|
||||
}
|
||||
case poll > fanPollFloor && readDur < fanPollFloor:
|
||||
poll = maxDuration(poll*2/3, fanPollFloor)
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
goodSamples++
|
||||
|
||||
for _, f := range fans {
|
||||
if csvFile != nil {
|
||||
fmt.Fprintf(csvFile, "%.0f,%s,%.0f\n", elapsed, f.Name, f.RPM)
|
||||
}
|
||||
st := fanBy[f.Name]
|
||||
if st == nil {
|
||||
fanBy[f.Name] = &fanState{peak: f.RPM, lastRiseSec: elapsed}
|
||||
@@ -270,7 +301,11 @@ loop:
|
||||
}
|
||||
}
|
||||
|
||||
if elapsed >= float64(opts.MinLoadSec) && time.Since(readyAt) >= time.Duration(opts.PlateauHoldSec)*time.Second && len(fanBy) > 0 {
|
||||
// Only trust a plateau while telemetry is healthy and we have enough
|
||||
// recent samples to have actually seen a flat window.
|
||||
healthy := poll <= 2*fanPollFloor && goodSamples >= 5
|
||||
if healthy && rampConfirmed && elapsed >= float64(opts.MinLoadSec) &&
|
||||
time.Since(readyAt) >= time.Duration(opts.PlateauHoldSec)*time.Second && len(fanBy) > 0 {
|
||||
allFlat := true
|
||||
for _, st := range fanBy {
|
||||
if elapsed-st.lastRiseSec < float64(opts.PlateauHoldSec) {
|
||||
@@ -278,7 +313,7 @@ loop:
|
||||
break
|
||||
}
|
||||
}
|
||||
if allFlat && rampConfirmed {
|
||||
if allFlat {
|
||||
plateauReached = true
|
||||
logFunc(fmt.Sprintf("All %d fans plateaued at %.0fs of load", len(fanBy), elapsed))
|
||||
break loop
|
||||
@@ -295,6 +330,8 @@ loop:
|
||||
return runDir, ctx.Err()
|
||||
}
|
||||
|
||||
loadDur := time.Since(start).Seconds()
|
||||
|
||||
// ── Verdict.
|
||||
statuses := readFanStatuses()
|
||||
var summary strings.Builder
|
||||
@@ -304,19 +341,12 @@ loop:
|
||||
fmt.Fprintf(&summary, "gpu_vendor=%s\n", orNone(haveGPU, vendor))
|
||||
fmt.Fprintf(&summary, "plateau_reached=%v\n", plateauReached)
|
||||
fmt.Fprintf(&summary, "ramp_confirmed=%v\n", rampConfirmed)
|
||||
if len(rows) > 0 {
|
||||
fmt.Fprintf(&summary, "load_duration_sec=%.0f\n", rows[len(rows)-1].ElapsedSec)
|
||||
fmt.Fprintf(&summary, "load_duration_sec=%.0f\n", loadDur)
|
||||
fmt.Fprintf(&summary, "fan_samples=%d\n", goodSamples)
|
||||
fmt.Fprintf(&summary, "telemetry_degraded=%v\n", degraded)
|
||||
if t := boundedGPUMaxTemp(opts.GPUIndices); t > 0 {
|
||||
fmt.Fprintf(&summary, "gpu_temp_c=%.0f\n", t)
|
||||
}
|
||||
fmt.Fprintf(&summary, "max_gpu_temp_c=%.1f\n", analyzeMaxTemp(rows, func(r FanStressRow) float64 {
|
||||
var m float64
|
||||
for _, g := range r.GPUs {
|
||||
if g.TempC > m {
|
||||
m = g.TempC
|
||||
}
|
||||
}
|
||||
return m
|
||||
}))
|
||||
fmt.Fprintf(&summary, "max_cpu_temp_c=%.1f\n", analyzeMaxTemp(rows, func(r FanStressRow) float64 { return r.CPUMaxTempC }))
|
||||
|
||||
stats := satStats{}
|
||||
names := make([]string, 0, len(baselineRPM))
|
||||
@@ -349,14 +379,126 @@ loop:
|
||||
}
|
||||
writeSATStats(&summary, stats)
|
||||
|
||||
_ = WriteFanStressCSV(filepath.Join(runDir, "metrics.csv"), rows, opts.GPUIndices)
|
||||
_ = WriteFanSensorsCSV(filepath.Join(runDir, "fan-sensors.csv"), rows)
|
||||
if err := os.WriteFile(filepath.Join(runDir, "summary.txt"), []byte(summary.String()), 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return runDir, nil
|
||||
}
|
||||
|
||||
func errString(err error) string {
|
||||
if err == nil {
|
||||
return "no GPU stress tool"
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func minDuration(a, b time.Duration) time.Duration {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func maxDuration(a, b time.Duration) time.Duration {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// readFansBounded runs "ipmitool sdr type Fan" but never blocks the caller
|
||||
// longer than timeout: the read happens in a goroutine that is abandoned if it
|
||||
// does not return in time (a KCS read wedged in uninterruptible I/O cannot be
|
||||
// killed, so we leave it and move on). ok=false means "no usable sample this
|
||||
// tick" — the caller must treat that as missing data, not as a flat fan.
|
||||
func readFansBounded(timeout time.Duration) ([]FanReading, bool) {
|
||||
type result struct {
|
||||
fans []FanReading
|
||||
ok bool
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
out, err := exec.Command("ipmitool", "sdr", "type", "Fan").Output()
|
||||
if err != nil {
|
||||
ch <- result{}
|
||||
return
|
||||
}
|
||||
fans := parseFanSpeeds(string(out))
|
||||
if len(fans) == 0 {
|
||||
ch <- result{}
|
||||
return
|
||||
}
|
||||
ch <- result{fans, true}
|
||||
}()
|
||||
select {
|
||||
case r := <-ch:
|
||||
if r.ok {
|
||||
updateFanObservation(r.fans, time.Now())
|
||||
}
|
||||
return r.fans, r.ok
|
||||
case <-time.After(timeout):
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// boundedGPUMaxTemp returns the hottest GPU temperature via a single
|
||||
// time-boxed nvidia-smi call, or 0 if unavailable.
|
||||
func boundedGPUMaxTemp(gpuIndices []int) float64 {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
|
||||
defer cancel()
|
||||
args := []string{"--query-gpu=temperature.gpu", "--format=csv,noheader,nounits"}
|
||||
if len(gpuIndices) > 0 {
|
||||
ids := make([]string, len(gpuIndices))
|
||||
for i, idx := range gpuIndices {
|
||||
ids[i] = strconv.Itoa(idx)
|
||||
}
|
||||
args = append([]string{"--id=" + strings.Join(ids, ",")}, args...)
|
||||
}
|
||||
out, err := exec.CommandContext(ctx, "nvidia-smi", args...).Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
var max float64
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if v, err := strconv.ParseFloat(strings.TrimSpace(line), 64); err == nil && v > max {
|
||||
max = v
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
// buildFanCheckGPULoadCmd builds the hottest sustained GPU load for the fan
|
||||
// check. NVIDIA uses the Power/Thermal Fit engine (dcgmproftester -t 1004 /
|
||||
// targeted_power); AMD uses the RVS gst stressor.
|
||||
func buildFanCheckGPULoadCmd(ctx context.Context, vendor string, durSec int, gpuIndices []int) (*exec.Cmd, string, error) {
|
||||
switch strings.ToLower(vendor) {
|
||||
case "nvidia":
|
||||
argv, env, err := resolveBenchmarkPowerLoadCommand(durSec, gpuIndices)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||
if len(env) > 0 {
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process != nil {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return cmd, "dcgmproftester targeted_power", nil
|
||||
case "amd":
|
||||
cmd := buildAMDGPUStressCmd(ctx, durSec)
|
||||
if cmd == nil {
|
||||
return nil, "", nil
|
||||
}
|
||||
return cmd, "rvs gst", nil
|
||||
}
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
func applyFanCheckDefaults(o *FanCheckOptions) {
|
||||
if o.PlateauHoldSec <= 0 {
|
||||
o.PlateauHoldSec = 60
|
||||
@@ -459,68 +601,6 @@ func ResolveFanMaxRPM(current map[string]float64) map[string]float64 {
|
||||
return out
|
||||
}
|
||||
|
||||
// sampleFanStressRow collects all metrics for one telemetry sample.
|
||||
func sampleFanStressRow(gpuIndices []int, phase string, elapsed float64) FanStressRow {
|
||||
row := FanStressRow{
|
||||
TimestampUTC: time.Now().UTC().Format(time.RFC3339),
|
||||
ElapsedSec: elapsed,
|
||||
Phase: phase,
|
||||
}
|
||||
row.GPUs = sampleGPUStressMetrics(gpuIndices)
|
||||
row.Fans, _ = sampleFanSpeeds()
|
||||
row.CPUMaxTempC = sampleCPUMaxTemp()
|
||||
row.SysPowerW, row.SysPowerSource, row.SysPowerMode = sampleSystemPowerResolved()
|
||||
return row
|
||||
}
|
||||
|
||||
// sampleGPUStressMetrics queries nvidia-smi for temperature, utilization, power,
|
||||
// clock frequency, and active throttle reasons for each GPU.
|
||||
func sampleGPUStressMetrics(gpuIndices []int) []GPUStressMetric {
|
||||
args := []string{
|
||||
"--query-gpu=index,temperature.gpu,utilization.gpu,power.draw,clocks.current.graphics,clocks_throttle_reasons.active",
|
||||
"--format=csv,noheader,nounits",
|
||||
}
|
||||
if len(gpuIndices) > 0 {
|
||||
ids := make([]string, len(gpuIndices))
|
||||
for i, idx := range gpuIndices {
|
||||
ids[i] = strconv.Itoa(idx)
|
||||
}
|
||||
args = append([]string{"--id=" + strings.Join(ids, ",")}, args...)
|
||||
}
|
||||
out, err := exec.Command("nvidia-smi", args...).Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var metrics []GPUStressMetric
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(line, ", ")
|
||||
if len(parts) < 6 {
|
||||
continue
|
||||
}
|
||||
idx, _ := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
throttleVal := strings.TrimSpace(parts[5])
|
||||
// Throttled if active reasons bitmask is non-zero.
|
||||
throttled := throttleVal != "0x0000000000000000" &&
|
||||
throttleVal != "0x0" &&
|
||||
throttleVal != "0" &&
|
||||
throttleVal != "" &&
|
||||
throttleVal != "N/A"
|
||||
metrics = append(metrics, GPUStressMetric{
|
||||
Index: idx,
|
||||
TempC: parseGPUFloat(parts[1]),
|
||||
UsagePct: parseGPUFloat(parts[2]),
|
||||
PowerW: parseGPUFloat(parts[3]),
|
||||
ClockMHz: parseGPUFloat(parts[4]),
|
||||
Throttled: throttled,
|
||||
})
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
// sampleFanSpeeds reads fan RPM values from ipmitool sdr.
|
||||
func sampleFanSpeeds() ([]FanReading, error) {
|
||||
out, err := exec.Command("ipmitool", "sdr", "type", "Fan").Output()
|
||||
@@ -940,21 +1020,6 @@ func sampleCPUTempViaSensors() float64 {
|
||||
return max
|
||||
}
|
||||
|
||||
// sampleSystemPowerResolved reads system power via the global autotune source,
|
||||
// falling back to the historical heuristic before autotune or when degraded.
|
||||
func sampleSystemPowerResolved() (float64, string, string) {
|
||||
now := time.Now()
|
||||
current, decision, err := SampleSystemPowerResolved("")
|
||||
systemPowerCacheMu.Lock()
|
||||
defer systemPowerCacheMu.Unlock()
|
||||
if err != nil {
|
||||
current = 0
|
||||
}
|
||||
value, updated := effectiveSystemPowerReading(systemPowerCache, current, decision.EffectiveSource, decision.Mode, decision.Reason, now)
|
||||
systemPowerCache = updated
|
||||
return value, updated.Source, updated.Mode
|
||||
}
|
||||
|
||||
// parseDCMIPowerReading extracts the instantaneous power reading from ipmitool dcmi output.
|
||||
// Sample: " Instantaneous power reading: 500 Watts"
|
||||
func parseDCMIPowerReading(raw string) float64 {
|
||||
@@ -986,76 +1051,6 @@ func effectiveSystemPowerReading(cache cachedPowerReading, current float64, sour
|
||||
return 0, cache
|
||||
}
|
||||
|
||||
// analyzeMaxTemp returns the maximum value of the given extractor across all rows.
|
||||
func analyzeMaxTemp(rows []FanStressRow, extract func(FanStressRow) float64) float64 {
|
||||
var max float64
|
||||
for _, row := range rows {
|
||||
if v := extract(row); v > max {
|
||||
max = v
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
// WriteFanStressCSV writes the wide-format metrics CSV with one row per second.
|
||||
// GPU columns are generated per index in gpuIndices order.
|
||||
func WriteFanStressCSV(path string, rows []FanStressRow, gpuIndices []int) error {
|
||||
if len(rows) == 0 {
|
||||
return os.WriteFile(path, []byte("no data\n"), 0644)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
// Header: fixed system columns + per-GPU columns.
|
||||
b.WriteString("timestamp_utc,elapsed_sec,phase,fan_avg_rpm,fan_min_rpm,fan_max_rpm,cpu_max_temp_c,sys_power_w")
|
||||
for _, idx := range gpuIndices {
|
||||
fmt.Fprintf(&b, ",gpu%d_temp_c,gpu%d_usage_pct,gpu%d_power_w,gpu%d_clock_mhz,gpu%d_throttled",
|
||||
idx, idx, idx, idx, idx)
|
||||
}
|
||||
b.WriteRune('\n')
|
||||
|
||||
for _, row := range rows {
|
||||
favg, fmin, fmax := fanRPMStats(row.Fans)
|
||||
fmt.Fprintf(&b, "%s,%.1f,%s,%.0f,%.0f,%.0f,%.1f,%.1f",
|
||||
row.TimestampUTC,
|
||||
row.ElapsedSec,
|
||||
row.Phase,
|
||||
favg, fmin, fmax,
|
||||
row.CPUMaxTempC,
|
||||
row.SysPowerW,
|
||||
)
|
||||
gpuByIdx := make(map[int]GPUStressMetric, len(row.GPUs))
|
||||
for _, g := range row.GPUs {
|
||||
gpuByIdx[g.Index] = g
|
||||
}
|
||||
for _, idx := range gpuIndices {
|
||||
g := gpuByIdx[idx]
|
||||
throttled := 0
|
||||
if g.Throttled {
|
||||
throttled = 1
|
||||
}
|
||||
fmt.Fprintf(&b, ",%.1f,%.1f,%.1f,%.0f,%d",
|
||||
g.TempC, g.UsagePct, g.PowerW, g.ClockMHz, throttled)
|
||||
}
|
||||
b.WriteRune('\n')
|
||||
}
|
||||
|
||||
return os.WriteFile(path, []byte(b.String()), 0644)
|
||||
}
|
||||
|
||||
// WriteFanSensorsCSV writes individual fan sensor readings in long (tidy) format.
|
||||
func WriteFanSensorsCSV(path string, rows []FanStressRow) error {
|
||||
var b strings.Builder
|
||||
b.WriteString("timestamp_utc,elapsed_sec,phase,fan_name,rpm\n")
|
||||
for _, row := range rows {
|
||||
for _, f := range row.Fans {
|
||||
fmt.Fprintf(&b, "%s,%.1f,%s,%s,%.0f\n",
|
||||
row.TimestampUTC, row.ElapsedSec, row.Phase, f.Name, f.RPM)
|
||||
}
|
||||
}
|
||||
return os.WriteFile(path, []byte(b.String()), 0644)
|
||||
}
|
||||
|
||||
// fanRPMStats computes average, min, max RPM across all fans in a sample row.
|
||||
func fanRPMStats(fans []FanReading) (avg, min, max float64) {
|
||||
if len(fans) == 0 {
|
||||
|
||||
Reference in New Issue
Block a user