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
+106
View File
@@ -0,0 +1,106 @@
package metrics
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// A fake /sys/class/powercap: a package with its parts, the platform domain, and the MMIO
// copy of the same package. Only the package counts.
func fakeRAPL(t *testing.T, domains map[string][2]string) string {
t.Helper()
root := t.TempDir()
for dir, v := range domains {
d := filepath.Join(root, dir)
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(d, "name"), []byte(v[0]+"\n"), 0o644)
os.WriteFile(filepath.Join(d, "energy_uj"), []byte(v[1]+"\n"), 0o644)
}
return root
}
func TestRAPLPackagesOnlyCountsPackages(t *testing.T) {
root := fakeRAPL(t, map[string][2]string{
"intel-rapl:0": {"package-0", "1000"},
"intel-rapl:0:0": {"core", "600"},
"intel-rapl:0:1": {"uncore", "10"},
"intel-rapl:1": {"psys", "300"},
"intel-rapl-mmio:0": {"package-0", "1000"},
})
if got := raplPackages(root); len(got) != 1 || got[0] != "intel-rapl:0" {
t.Fatalf("packages = %v, want [intel-rapl:0]", got)
}
two := fakeRAPL(t, map[string][2]string{"intel-rapl:0": {"package-0", "5"}, "intel-rapl:1": {"package-1", "7"}})
got := raplPackages(two)
if strings.Join(got, ",") != "intel-rapl:0,intel-rapl:1" {
t.Fatalf("two sockets: packages = %v", got)
}
cs, ok := readRAPL(two, got)
if !ok || len(cs) != 2 || cs[0].UJ != 5 || cs[1].UJ != 7 {
t.Fatalf("readRAPL = %+v, %v", cs, ok)
}
if got := raplPackages(filepath.Join(two, "nope")); len(got) != 0 {
t.Fatalf("no powercap: packages = %v, want none", got)
}
}
func TestRAPLWatts(t *testing.T) {
c := func(d string, uj uint64) raplCount { return raplCount{d, uj} }
max := map[string]uint64{"intel-rapl:0": 1_000_000_000}
cases := []struct {
name string
prev, cur []raplCount
dt float64
want float64
ok bool
}{
{"steady", []raplCount{c("intel-rapl:0", 0)}, []raplCount{c("intel-rapl:0", 10_000_000)}, 2, 5, true},
{"two packages add", []raplCount{c("intel-rapl:0", 0), c("intel-rapl:1", 0)}, []raplCount{c("intel-rapl:0", 4_000_000), c("intel-rapl:1", 6_000_000)}, 1, 10, true},
{"wrapped", []raplCount{c("intel-rapl:0", 999_000_000)}, []raplCount{c("intel-rapl:0", 1_000_000)}, 1, 2, true},
{"backwards, range unknown", []raplCount{c("intel-rapl:1", 5_000_000)}, []raplCount{c("intel-rapl:1", 1)}, 1, 0, false},
{"counters changed", []raplCount{c("intel-rapl:0", 0)}, []raplCount{c("intel-rapl:1", 9)}, 1, 0, false},
{"first reading", nil, []raplCount{c("intel-rapl:0", 9)}, 1, 0, false},
{"no time passed", []raplCount{c("intel-rapl:0", 0)}, []raplCount{c("intel-rapl:0", 9)}, 0, 0, false},
}
for _, tc := range cases {
got, ok := raplWatts(tc.prev, tc.cur, max, tc.dt)
if ok != tc.ok || (ok && (got < tc.want-1e-9 || got > tc.want+1e-9)) {
t.Errorf("%s: got %v, %v; want %v, %v", tc.name, got, ok, tc.want, tc.ok)
}
}
}
// The helper answers every newline with the counters it found itself, and stops at EOF.
func TestCPUEnergyHelper(t *testing.T) {
root := fakeRAPL(t, map[string][2]string{"intel-rapl:0": {"package-0", "42"}, "intel-rapl:0:0": {"core", "7"}})
var out bytes.Buffer
if code := cpuEnergyHelper(root, strings.NewReader("\n\n"), &out); code != 0 {
t.Fatalf("exit %d", code)
}
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
if len(lines) != 2 {
t.Fatalf("got %d lines, want 2: %q", len(lines), out.String())
}
var cs []raplCount
if err := json.Unmarshal([]byte(lines[1]), &cs); err != nil || len(cs) != 1 || cs[0] != (raplCount{"intel-rapl:0", 42}) {
t.Fatalf("line = %s (%v)", lines[1], err)
}
}
// History keeps the average of the samples that had a reading.
func TestFoldCPUPower(t *testing.T) {
b := []Sample{{T: 1, CPUPower: 4}, {T: 2}, {T: 3, CPUPower: 8}}
if got := fold(b).CPUPower; got != 6 {
t.Fatalf("folded CPU power = %v, want 6", got)
}
if got := fold([]Sample{{T: 1}, {T: 2}}).CPUPower; got != 0 {
t.Fatalf("no readings folded to %v, want 0", got)
}
}