Files
hamlab/internal/metrics/cpupower.go
Mitchell-JohnsonandClaude Opus 5.5 bfedf622be 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>
2026-10-10 23:58:35 -07:00

260 lines
6.9 KiB
Go

package metrics
// CPU power, from the RAPL energy counters the kernel keeps under /sys/class/powercap: Intel
// since Sandy Bridge, and AMD Zen through the same driver. Each counter is a running total in
// µJ, so power is the difference between two readings over the time between them. Every
// package is summed (`package-0`, and `package-1` on a two-socket board). The other top-level
// domains are left out. `psys` claims the whole platform but is only as good as the firmware's
// calibration, and on one machine here it reads lower than the package inside it. The MMIO
// copy of a package (`intel-rapl-mmio:0`) measures the same thing again. This is the CPU's own
// figure, shown beside its clock: cores, built-in graphics and memory controller, not disks,
// fans or the power supply.
//
// The counters are root-only since the PLATYPUS side channel (2020). So, like the GPU client
// walk, they are read in-process when the daemon is root, and otherwise by a root helper,
// `hamlab cpu-energy`, started once through sudo or doas and asked over a pipe at every
// sample: one privilege escalation per daemon, not one a second in the auth log. The helper
// takes nothing from the daemon but the newline that asks; it finds and reads the counters
// itself. Without root there is no reading, and nothing is shown.
import (
"bufio"
"encoding/json"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"hamlab/internal/priv"
)
const raplRoot = "/sys/class/powercap"
var raplPkgRe = regexp.MustCompile(`^package-\d+$`)
// raplCount is one package's counter at one moment.
type raplCount struct {
Dir string `json:"d"`
UJ uint64 `json:"uj"`
}
type cpuPowerState struct {
probed bool
dirs []string // the package domains, found at the first sample
max map[string]uint64 // each counter's range: where it wraps to zero
useHelper bool // the counters are not readable in-process
helper *energyHelper
prev []raplCount
prevAt time.Time
}
// raplPackages lists the package domains under root, sorted. intel-rapl:0 is a package;
// intel-rapl:0:0 is a part of one (core, uncore, dram).
func raplPackages(root string) []string {
ents, err := os.ReadDir(root)
if err != nil {
return nil
}
var out []string
for _, e := range ents {
n := e.Name()
if !strings.HasPrefix(n, "intel-rapl:") || strings.Count(n, ":") != 1 {
continue
}
if raplPkgRe.MatchString(strings.TrimSpace(readStr(filepath.Join(root, n, "name")))) {
out = append(out, n)
}
}
sort.Strings(out)
return out
}
func readRAPL(root string, dirs []string) ([]raplCount, bool) {
out := make([]raplCount, 0, len(dirs))
for _, d := range dirs {
b, err := os.ReadFile(filepath.Join(root, d, "energy_uj"))
if err != nil {
return nil, false
}
v, err := strconv.ParseUint(strings.TrimSpace(string(b)), 10, 64)
if err != nil {
return nil, false
}
out = append(out, raplCount{d, v})
}
return out, len(out) > 0
}
// raplWatts is the power between two readings of the same counters. A counter that wrapped
// is carried across its range. One that went backwards with no known range, or a set of
// counters that changed between readings, gives no answer rather than a wrong one.
func raplWatts(prev, cur []raplCount, max map[string]uint64, dt float64) (float64, bool) {
if dt <= 0 || len(prev) == 0 || len(prev) != len(cur) {
return 0, false
}
var uj float64
for i := range cur {
if cur[i].Dir != prev[i].Dir {
return 0, false
}
if cur[i].UJ >= prev[i].UJ {
uj += float64(cur[i].UJ - prev[i].UJ)
continue
}
m := max[cur[i].Dir]
if m == 0 || prev[i].UJ > m {
return 0, false
}
uj += float64(m - prev[i].UJ + cur[i].UJ)
}
return uj / 1e6 / dt, true
}
// cpuPower is the CPU packages' power since the previous sample, in watts; 0 when this
// machine has no counters or no way to read them.
func (c *Collector) cpuPower(now time.Time) float64 {
p := &c.power
if !p.probed {
p.probed = true
p.dirs = raplPackages(raplRoot)
p.max = map[string]uint64{}
for _, d := range p.dirs {
if v, ok := readInt(filepath.Join(raplRoot, d, "max_energy_range_uj")); ok && v > 0 {
p.max[d] = uint64(v)
}
}
}
if len(p.dirs) == 0 {
return 0
}
var cur []raplCount
ok := false
if !p.useHelper {
if cur, ok = readRAPL(raplRoot, p.dirs); !ok && priv.Mode() != "root" && priv.Available() {
p.useHelper = true
}
}
if p.useHelper {
if p.helper == nil {
p.helper = &energyHelper{}
}
cur, ok = p.helper.read()
}
if !ok {
p.prev = nil
return 0
}
w, good := raplWatts(p.prev, cur, p.max, now.Sub(p.prevAt).Seconds())
p.prev, p.prevAt = cur, now
if !good {
return 0
}
return round1(w)
}
// energyHelper is the root child that answers each newline with the package counters, one
// JSON line. The same shape as the GPU client walk's helper, kept separate from it.
type energyHelper struct {
mu sync.Mutex
cmd *exec.Cmd
in io.WriteCloser
out *bufio.Reader
failedAt time.Time
}
func (h *energyHelper) read() ([]raplCount, bool) {
h.mu.Lock()
defer h.mu.Unlock()
if h.cmd == nil {
if !h.failedAt.IsZero() && time.Since(h.failedAt) < helperRetry {
return nil, false
}
if !h.start() {
h.failedAt = time.Now()
return nil, false
}
}
// a stuck child must not stall the sampler: it is killed, and tried again later
cmd := h.cmd
stop := time.AfterFunc(5*time.Second, func() { cmd.Process.Kill() })
defer stop.Stop()
if _, err := io.WriteString(h.in, "\n"); err != nil {
h.fail()
return nil, false
}
line, err := h.out.ReadBytes('\n')
if err != nil {
h.fail()
return nil, false
}
var cs []raplCount
if json.Unmarshal(line, &cs) != nil {
h.fail()
return nil, false
}
return cs, len(cs) > 0
}
func (h *energyHelper) start() bool {
self, err := os.Executable()
if err != nil {
return false
}
full := priv.Wrap(self, "cpu-energy")
cmd := exec.Command(full[0], full[1:]...)
in, err := cmd.StdinPipe()
if err != nil {
return false
}
out, err := cmd.StdoutPipe()
if err != nil {
return false
}
if err := cmd.Start(); err != nil {
return false
}
h.cmd, h.in, h.out = cmd, in, bufio.NewReader(out)
return true
}
func (h *energyHelper) fail() {
if h.cmd != nil {
h.in.Close()
h.cmd.Process.Kill()
go h.cmd.Wait()
}
h.cmd, h.in, h.out = nil, nil, nil
h.failedAt = time.Now()
}
// CPUEnergyHelper is `hamlab cpu-energy`: for every line read on stdin, one JSON line of the
// package counters on stdout. Ends when stdin does — when the daemon that started it has gone.
func CPUEnergyHelper(stdin io.Reader, stdout io.Writer) int {
return cpuEnergyHelper(raplRoot, stdin, stdout)
}
func cpuEnergyHelper(root string, stdin io.Reader, stdout io.Writer) int {
dirs := raplPackages(root)
sc := bufio.NewScanner(stdin)
w := bufio.NewWriter(stdout)
for sc.Scan() {
cs, ok := readRAPL(root, dirs)
if !ok {
cs = []raplCount{}
}
b, _ := json.Marshal(cs)
w.Write(b)
w.WriteByte('\n')
if w.Flush() != nil {
return 1
}
}
return 0
}