diff --git a/internal/metrics/gpu.go b/internal/metrics/gpu.go index bdf7006..f21b55e 100644 --- a/internal/metrics/gpu.go +++ b/internal/metrics/gpu.go @@ -49,6 +49,7 @@ type GPU struct { PowerW float64 `json:"power_w,omitempty"` PowerCapW float64 `json:"power_cap_w,omitempty"` FanRPM int `json:"fan_rpm,omitempty"` + FanPct *float64 `json:"fan_pct,omitempty"` // fan speed as a % of its range: nvidia-smi's fan.speed, or the hwmon's pwm1; nil when the card has no such reading (0 is a stopped fan) MHz float64 `json:"mhz,omitempty"` // actual clock (0 when power-gated) ReqMHz float64 `json:"req_mhz,omitempty"` // what the driver asked for MaxMHz float64 `json:"max_mhz,omitempty"` @@ -479,6 +480,14 @@ func (d *gpuDev) read(x *GPU, now time.Time) { x.PowerCapW = p / 1e6 } x.FanRPM = int(readF(d.hwmon + "/fan1_input")) + if pwm, err := strconv.ParseFloat(strings.TrimSpace(readStr(d.hwmon+"/pwm1")), 64); err == nil { + top := readF(d.hwmon + "/pwm1_max") + if top <= 0 { + top = 255 + } + v := round1(pwm / top * 100) + x.FanPct = &v + } } d.prevAt = now } @@ -762,7 +771,7 @@ func ueventValue(p, key string) string { // sample (~20 ms and ~75 ms on a GTX 965M), no root needed. The PCI address selects the // card, so two NVIDIA GPUs stay apart. func (d *gpuDev) readNvidia(x *GPU) { - if out, ok := runOut("nvidia-smi", "--id="+d.bus, "--query-gpu=utilization.gpu,utilization.encoder,utilization.decoder,memory.used,memory.total,clocks.sm,clocks.max.sm,temperature.gpu,power.draw,power.limit", "--format=csv,noheader,nounits"); ok { + if out, ok := runOut("nvidia-smi", "--id="+d.bus, "--query-gpu=utilization.gpu,utilization.encoder,utilization.decoder,memory.used,memory.total,clocks.sm,clocks.max.sm,temperature.gpu,power.draw,power.limit,fan.speed", "--format=csv,noheader,nounits"); ok { parseNvidiaQuery(x, out) } if out, ok := runOut("nvidia-smi", "pmon", "-c", "1", "-s", "um", "--id="+d.bus); ok { @@ -781,7 +790,8 @@ func nvNum(s string) (float64, bool) { } // parseNvidiaQuery fills a GPU from one --query-gpu line in the field order readNvidia asks -// for: gpu%, encoder%, decoder%, mem used MiB, mem total MiB, SM MHz, max SM MHz, °C, W, W cap. +// for: gpu%, encoder%, decoder%, mem used MiB, mem total MiB, SM MHz, max SM MHz, °C, W, W cap, +// fan % ("[N/A]" on a card with no fan of its own: a laptop's, a passive datacentre card). func parseNvidiaQuery(x *GPU, line string) { f := strings.Split(strings.TrimSpace(line), ",") if len(f) < 10 { @@ -817,6 +827,11 @@ func parseNvidiaQuery(x *GPU, line string) { if v, ok := nvNum(f[9]); ok { x.PowerCapW = v } + if len(f) > 10 { + if v, ok := nvNum(f[10]); ok { + x.FanPct = &v + } + } } // parseNvidiaPmon reads `nvidia-smi pmon -c 1 -s um`: a header naming the columns, then one diff --git a/internal/metrics/gpu_test.go b/internal/metrics/gpu_test.go index d2620b5..c4bbf5a 100644 --- a/internal/metrics/gpu_test.go +++ b/internal/metrics/gpu_test.go @@ -182,6 +182,20 @@ func TestParseNvidiaQuery(t *testing.T) { if g.Temp != 57 || g.TempSrc != "gpu" || g.PowerW != 23.4 || g.PowerCapW != 0 { t.Errorf("temp/power (cap must stay 0 for [N/A]): %+v", g) } + if g.FanPct != nil { + t.Errorf("no fan field must leave the fan unset: %+v", g) + } + var fan, nofan GPU + parseNvidiaQuery(&fan, "99, 0, 0, 20000, 24576, 1695, 2100, 83, 403.99, 420.00, 34\n") + parseNvidiaQuery(&nofan, "5, 0, 0, 500, 2048, 300, 1097, 45, 8.0, [N/A], [N/A]\n") + if fan.FanPct == nil || *fan.FanPct != 34 || nofan.FanPct != nil { + t.Errorf("fan: %v, [N/A]: %v", fan.FanPct, nofan.FanPct) + } + var stopped GPU + parseNvidiaQuery(&stopped, "0, 0, 0, 500, 24576, 210, 2100, 35, 20.0, 420.00, 0\n") + if stopped.FanPct == nil || *stopped.FanPct != 0 { + t.Errorf("a stopped fan must read 0, not nothing: %v", stopped.FanPct) + } var short GPU parseNvidiaQuery(&short, "garbage") if short.UsageSrc != "" { diff --git a/internal/metrics/sensors.go b/internal/metrics/sensors.go index a9956dc..474d9bf 100644 --- a/internal/metrics/sensors.go +++ b/internal/metrics/sensors.go @@ -181,6 +181,9 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor { if g.FanRPM > 0 { f.add(p+".fan", g.Name+" fan", "fan", "rpm", float64(g.FanRPM), t, none, lbl...) } + if g.FanPct != nil { + f.add(p+".fan_pct", g.Name+" fan", "fan", "%", *g.FanPct, t, pct, lbl...) + } if g.MaxMHz > 0 { f.add(p+".freq", g.Name+" clock", "frequency", "MHz", g.MHz, t, upTo(g.MaxMHz), lbl...) } diff --git a/web/src/Top.tsx b/web/src/Top.tsx index dbdcda0..d7f67d4 100644 --- a/web/src/Top.tsx +++ b/web/src/Top.tsx @@ -624,7 +624,7 @@ function GPUBox({ g, values, tick, advance, now, cap }: { g: GPU; values: number {Math.round(g.mhz ?? 0)}{g.max_mhz ? ` / ${Math.round(g.max_mhz)}` : ''} MHz : null} {(g.power_w !== undefined && g.power_w > 0) || g.power_cap_w ?