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>
260 lines
6.9 KiB
Go
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
|
|
}
|