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>
107 lines
3.9 KiB
Go
107 lines
3.9 KiB
Go
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)
|
|
}
|
|
}
|