Files
Mitchell-JohnsonandClaude Opus 5.5 e9a87af5e8 Top: each CPU's own wattage on a two-socket machine, its threads folded under it
Per-package RAPL watts in the sample, the process table and as cpu.package.N.power
sensors. The socket rows collapse with an arrow; the fold is remembered per browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:07:24 -08:00

297 lines
8.1 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) {
each, ok := raplWattsEach(prev, cur, max, dt)
var w float64
for _, x := range each {
w += x
}
return w, ok
}
// raplWattsEach is raplWatts per counter: one package each.
func raplWattsEach(prev, cur []raplCount, max map[string]uint64, dt float64) ([]float64, bool) {
if dt <= 0 || len(prev) == 0 || len(prev) != len(cur) {
return nil, false
}
out := make([]float64, len(cur))
for i := range cur {
if cur[i].Dir != prev[i].Dir {
return nil, false
}
if cur[i].UJ >= prev[i].UJ {
out[i] = float64(cur[i].UJ-prev[i].UJ) / 1e6 / dt
continue
}
m := max[cur[i].Dir]
if m == 0 || prev[i].UJ > m {
return nil, false
}
out[i] = float64(m-prev[i].UJ+cur[i].UJ) / 1e6 / dt
}
return out, 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. On a machine with more than one package
// it also gives each one's, indexed by package id (the socket).
func (c *Collector) cpuPower(now time.Time) (float64, []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, nil
}
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, nil
}
each, good := raplWattsEach(p.prev, cur, p.max, now.Sub(p.prevAt).Seconds())
p.prev, p.prevAt = cur, now
if !good {
return 0, nil
}
var w float64
for _, x := range each {
w += x
}
return round1(w), perPackage(cur, each)
}
// perPackage puts each counter's watts at its package's id — intel-rapl:1 is "package-1" —
// and is nil for a single package, whose figure is the total.
func perPackage(cur []raplCount, each []float64) []float64 {
if len(cur) < 2 {
return nil
}
var out []float64
for i, c := range cur {
id := i
if n, ok := strings.CutPrefix(strings.TrimSpace(readStr(filepath.Join(raplRoot, c.Dir, "name"))), "package-"); ok {
if v, err := strconv.Atoi(n); err == nil && v >= 0 && v < 64 {
id = v
}
}
for len(out) <= id {
out = append(out, 0)
}
out[id] = round1(each[i])
}
return out
}
// 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
}