Processes: CPU power beside the clock, from the packages' energy counters

New sensor cpu.power: watts from the RAPL energy counters (Intel, and AMD
Zen through the same driver), summed over every package and averaged into
history. Parts of a package, psys and the MMIO duplicate are left out.

The counters are root-only, so a daemon that isn't root reads them through
`hamlab cpu-energy`, a root helper started once with sudo or doas and asked
over a pipe each sample, the same shape as the GPU client walk's helper.
It reads only the counters it finds itself. No counters or no root means
no reading, and the CPU box shows nothing extra.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-10-10 23:58:35 -07:00
co-authored by Claude Opus 5.5
parent d5680e8e41
commit bfedf622be
9 changed files with 398 additions and 5 deletions
+8 -2
View File
@@ -6,7 +6,9 @@
// lm-sensors, smartmontools or root. Sections whose source is absent (no fans, no
// UPS daemon, no hwmon at all on a VM) simply come back empty rather than failing.
// The only external commands are optional UPS daemons: pwrstat (CyberPower), upsc
// (NUT) and apcaccess (apcupsd), tried in that order.
// (NUT) and apcaccess (apcupsd), tried in that order. Two readings are root-only, the GPU
// client walk and the CPU energy counters; without root they come from small helpers of
// this binary started once through sudo or doas (gpu.go, cpupower.go), or not at all.
package metrics
import (
@@ -137,6 +139,7 @@ type Sample struct {
Interval int64 `json:"interval_ms"` // sampling interval in effect
CPU float64 `json:"cpu"`
Cores []float64 `json:"cores"`
CPUPower float64 `json:"cpu_power_w,omitempty"` // the CPU packages' own energy counters; 0 = no reading
Load [3]float64 `json:"load"`
Mem Mem `json:"mem"`
Swap Mem `json:"swap"`
@@ -177,6 +180,7 @@ type Collector struct {
lastTable *ProcTable
upsProbe func() *UPS // nil when no UPS daemon is installed
gpu gpuState
power cpuPowerState
prevARC arcPrev
subMu sync.Mutex
@@ -343,6 +347,7 @@ func (c *Collector) sample() {
s := Sample{T: now.UnixMilli(), Interval: c.Interval().Milliseconds(), Temps: []Temp{}, Fans: []Fan{}, Net: []Net{}, Disks: []Disk{}}
s.CPU, s.Cores = c.cpu()
s.CPUPower = c.cpuPower(now)
s.Load = loadavg()
s.Mem, s.Swap = meminfo()
s.Uptime = uptime()
@@ -1113,6 +1118,7 @@ type ProcTable struct {
CPUModel string `json:"cpu_model,omitempty"`
CPUMHz float64 `json:"cpu_mhz,omitempty"`
CPUMaxMHz float64 `json:"cpu_max_mhz,omitempty"`
CPUPowerW float64 `json:"cpu_power_w,omitempty"`
CPUTemp float64 `json:"cpu_temp,omitempty"`
CoreIDs []int `json:"core_ids,omitempty"` // for each logical CPU, the physical core it is a thread of (what the temperature sensors are named by)
Load [3]float64 `json:"load"`
@@ -1312,7 +1318,7 @@ func (c *Collector) Processes() ProcTable {
t.Mem, t.Swap = meminfo()
if n := len(c.ring); n > 0 {
last := c.ring[n-1]
t.CPU, t.Cores, t.Net, t.CPUTemp = last.CPU, last.Cores, last.Net, cpuTemp(last.Temps)
t.CPU, t.Cores, t.Net, t.CPUTemp, t.CPUPowerW = last.CPU, last.Cores, last.Net, cpuTemp(last.Temps), last.CPUPower
t.CoreIDs = coreIDs(len(last.Cores))
from := n - 90
if from < 0 {