Skip to content

Commit 831f8a8

Browse files
authored
feat: standardize power on cpu / gpu / total across every platform
Retire power.pkg and report the same three power metrics on every platform, the way btop/mactop/top do: - power.cpu — CPU (RAPL package on Linux, IOReport CPU on macOS) - power.gpu — GPU - power.total — whole machine (cpu+gpu+npu, or the SMC whole-system total on Apple) Fixes AMD APUs under-counting and the busy-GPU-hidden-behind-a-tiny-package headline. Top view and host detail both show CPU / GPU / total. power.cpu says why it's missing where there's no source (GB10 no RAPL, Windows no RAPL). Per-core CPU keeps rendering in the top view; cpu.cores now also carries the per-core avg.
1 parent dd5c02e commit 831f8a8

26 files changed

Lines changed: 190 additions & 248 deletions

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
77

88
## [Unreleased]
99

10+
### Changed
11+
- **Standardized power on three metrics everywhere: `power.cpu`, `power.gpu`,
12+
`power.total`** — the same CPU / GPU / total split btop, mactop, and top show.
13+
`power.pkg` is retired; the CPU rail is always `power.cpu`:
14+
- **Linux**: `power.cpu` = the RAPL **package** (whole CPU socket — what btop
15+
calls "CPU"); the unreliable RAPL `core` subdomain is dropped.
16+
- **macOS**: `power.cpu` = IOReport CPU; `power.total` = SMC `PSTR`
17+
(whole-system) — the SMC figure is no longer mislabelled `power.pkg`.
18+
- `power.total` = `cpu + gpu + npu` on every non-Apple host (Apple already
19+
reports a whole-system total). This fixes **AMD APUs (Strix Halo)**
20+
under-counting: RAPL package and amdgpu are separate rails and are now summed
21+
(matching btop's CPU + GPU).
22+
- Both the **top view** and the **host detail view** now show CPU, GPU, and total
23+
power.
24+
- `power.cpu` is reported Unavailable-with-reason where no source exists: GB10
25+
(no RAPL on Grace/ARM) and Windows (RAPL inaccessible).
26+
1027
## [2.2.9] - 2026-07-01
1128

1229
### Added

app/cmd/helper/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ func demoMetrics(context.Context) []domain.Metric {
102102
return []domain.Metric{
103103
{Name: "power.cpu", Unit: "watts", Status: domain.StatusOK, Gauge: 8.4},
104104
{Name: "power.gpu", Unit: "watts", Status: domain.StatusOK, Gauge: 3.1},
105-
{Name: "power.pkg", Unit: "watts", Status: domain.StatusOK, Gauge: 12.7},
105+
{Name: "power.total", Unit: "watts", Status: domain.StatusOK, Gauge: 11.5},
106106
{Name: "gpu.util", Unit: "percent", Status: domain.StatusOK, Gauge: 41},
107107
{Name: "gpu.vram", Unit: "percent", Status: domain.StatusOK, Gauge: 36},
108108
{Name: "gpu.temp", Unit: "celsius", Status: domain.StatusOK, Gauge: 54},

app/internal/adapters/cpu.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func (CPU) Collect(ctx context.Context) ([]domain.Metric, error) {
3636
if len(per) > 0 {
3737
out = append(out, domain.Metric{
3838
Name: "cpu.cores", Unit: "percent", Status: domain.StatusOK,
39-
Kind: domain.KindPerCore, PerCore: per,
39+
Kind: domain.KindPerCore, Gauge: avg, PerCore: per,
4040
})
4141
}
4242
return out, nil

app/internal/adapters/helper.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ type Helper struct {
3636
func (Helper) Describe() domain.AdapterInfo {
3737
return domain.AdapterInfo{
3838
ID: "privileged",
39-
Metrics: []string{"power.pkg", "power.cpu", "power.gpu", "power.npu", "gpu.util", "gpu.vram", "gpu.temp", "gpu.clock", "gpu.mem.util", "gpu.fan", "npu.util"},
39+
Metrics: []string{"power.total", "power.cpu", "power.gpu", "power.npu", "gpu.util", "gpu.vram", "gpu.temp", "gpu.clock", "gpu.mem.util", "gpu.fan", "npu.util"},
4040
RequiresPrivilege: true,
4141
}
4242
}
@@ -62,7 +62,7 @@ func (h Helper) Collect(ctx context.Context) ([]domain.Metric, error) {
6262
return ms, nil
6363
}
6464
return []domain.Metric{
65-
{Name: "power.pkg", Status: domain.StatusInsufficientPermission, Detail: "needs helper"},
65+
{Name: "power.cpu", Status: domain.StatusInsufficientPermission, Detail: "needs helper"},
6666
{Name: "gpu.util", Status: domain.StatusInsufficientPermission, Detail: "needs helper"},
6767
}, nil
6868
}

app/internal/adapters/helper_test.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ func TestHelperAdapterNeedsHelperWhenAbsent(t *testing.T) {
2222
a := Helper{
2323
Client: absentClient(t),
2424
Direct: func(context.Context) []domain.Metric {
25-
return []domain.Metric{{Name: "power.pkg", Status: domain.StatusUnavailable}}
25+
return []domain.Metric{{Name: "power.cpu", Status: domain.StatusUnavailable}}
2626
},
2727
}
2828
ms, err := a.Collect(context.Background())
@@ -33,7 +33,7 @@ func TestHelperAdapterNeedsHelperWhenAbsent(t *testing.T) {
3333
for _, m := range ms {
3434
got[m.Name] = m.Status
3535
}
36-
for _, name := range []string{"power.pkg", "gpu.util"} {
36+
for _, name := range []string{"power.cpu", "gpu.util"} {
3737
if got[name] != domain.StatusInsufficientPermission {
3838
t.Errorf("%s status = %v, want insufficient_permission", name, got[name])
3939
}
@@ -47,7 +47,7 @@ func TestHelperAdapterUsesDirectWhenPrivileged(t *testing.T) {
4747
Client: absentClient(t),
4848
Direct: func(context.Context) []domain.Metric {
4949
return []domain.Metric{
50-
{Name: "power.pkg", Unit: "W", Status: domain.StatusOK, Gauge: 12.5},
50+
{Name: "power.cpu", Unit: "W", Status: domain.StatusOK, Gauge: 12.5},
5151
{Name: "gpu.util", Unit: "%", Status: domain.StatusOK, Gauge: 40},
5252
}
5353
},
@@ -60,8 +60,8 @@ func TestHelperAdapterUsesDirectWhenPrivileged(t *testing.T) {
6060
for _, m := range ms {
6161
got[m.Name] = m
6262
}
63-
if got["power.pkg"].Status != domain.StatusOK || got["power.pkg"].Gauge != 12.5 {
64-
t.Errorf("power.pkg = %+v, want 12.5W ok", got["power.pkg"])
63+
if got["power.cpu"].Status != domain.StatusOK || got["power.cpu"].Gauge != 12.5 {
64+
t.Errorf("power.cpu = %+v, want 12.5W ok", got["power.cpu"])
6565
}
6666
if got["gpu.util"].Status != domain.StatusOK || got["gpu.util"].Gauge != 40 {
6767
t.Errorf("gpu.util = %+v, want 40%% ok", got["gpu.util"])
@@ -82,7 +82,7 @@ func (f fakeMetricClient) Collect(context.Context) ([]domain.Metric, error) { re
8282
func TestHelperEmptyReplyFallsBackToDirect(t *testing.T) {
8383
a := Helper{
8484
Client: fakeMetricClient{ms: []domain.Metric{
85-
{Name: "power.pkg", Status: domain.StatusInsufficientPermission, Detail: "needs helper"},
85+
{Name: "power.cpu", Status: domain.StatusInsufficientPermission, Detail: "needs helper"},
8686
{Name: "gpu.util", Status: domain.StatusInsufficientPermission, Detail: "needs helper"},
8787
}},
8888
Direct: func(context.Context) []domain.Metric {
@@ -110,15 +110,15 @@ func TestHelperOKReplyIsUsedWithoutDirect(t *testing.T) {
110110
directRan := false
111111
a := Helper{
112112
Client: fakeMetricClient{ms: []domain.Metric{
113-
{Name: "power.pkg", Unit: "W", Status: domain.StatusOK, Gauge: 15},
113+
{Name: "power.cpu", Unit: "W", Status: domain.StatusOK, Gauge: 15},
114114
}},
115115
Direct: func(context.Context) []domain.Metric { directRan = true; return nil },
116116
}
117117
ms, _ := a.Collect(context.Background())
118118
if directRan {
119119
t.Fatal("Direct must not run when the helper already has ok metrics")
120120
}
121-
if len(ms) != 1 || ms[0].Name != "power.pkg" || ms[0].Status != domain.StatusOK {
122-
t.Fatalf("want helper power.pkg ok, got %+v", ms)
121+
if len(ms) != 1 || ms[0].Name != "power.cpu" || ms[0].Status != domain.StatusOK {
122+
t.Fatalf("want helper power.cpu ok, got %+v", ms)
123123
}
124124
}

app/internal/domain/registry_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,15 +121,15 @@ func TestNonOKStatusPreserved(t *testing.T) {
121121
r := NewRegistry(time.Second)
122122
r.Register(okAdapter("gpu", []string{"gpu.util"},
123123
Metric{Name: "gpu.util", Status: StatusUnavailable, Detail: "no NVIDIA GPU"}))
124-
r.Register(okAdapter("power", []string{"power.pkg"},
125-
Metric{Name: "power.pkg", Status: StatusInsufficientPermission, Detail: "needs helper"}))
124+
r.Register(okAdapter("power", []string{"power.cpu"},
125+
Metric{Name: "power.cpu", Status: StatusInsufficientPermission, Detail: "needs helper"}))
126126

127127
got := metricsByName(r.Collect(context.Background()))
128128
if got["gpu.util"].Status != StatusUnavailable {
129129
t.Errorf("Unavailable coerced: %+v", got["gpu.util"])
130130
}
131-
if got["power.pkg"].Status != StatusInsufficientPermission {
132-
t.Errorf("InsufficientPermission coerced: %+v", got["power.pkg"])
131+
if got["power.cpu"].Status != StatusInsufficientPermission {
132+
t.Errorf("InsufficientPermission coerced: %+v", got["power.cpu"])
133133
}
134134
}
135135

app/internal/fake/fleet.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,16 @@ func metricsFor(h *spec) []domain.Metric {
174174
ms = append(ms, domain.Metric{Name: "gpu.util", Status: domain.StatusUnavailable, Detail: "no GPU"})
175175
}
176176
if h.hasPower {
177-
ms = append(ms, domain.Metric{Name: "power.pkg", Unit: "watts", Status: domain.StatusOK, Gauge: h.pwr})
177+
ms = append(ms, domain.Metric{Name: "power.cpu", Unit: "watts", Status: domain.StatusOK, Gauge: h.pwr, Detail: "CPU package"})
178+
total := h.pwr
179+
if h.hasGPU {
180+
gpuW := h.pwr * 1.5 // demo: a busy GPU pulls more than the CPU package
181+
ms = append(ms, domain.Metric{Name: "power.gpu", Unit: "watts", Status: domain.StatusOK, Gauge: gpuW})
182+
total += gpuW
183+
}
184+
ms = append(ms, domain.Metric{Name: "power.total", Unit: "watts", Status: domain.StatusOK, Gauge: total, Detail: "system total"})
178185
} else {
179-
ms = append(ms, domain.Metric{Name: "power.pkg", Status: domain.StatusInsufficientPermission, Detail: "needs helper"})
186+
ms = append(ms, domain.Metric{Name: "power.cpu", Status: domain.StatusInsufficientPermission, Detail: "needs helper"})
180187
}
181188
return ms
182189
}

app/internal/helper/collect.go

Lines changed: 25 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -39,21 +39,13 @@ func PrivilegedMetrics(ctx context.Context) []domain.Metric {
3939
out = mergeByName(out, linuxPrivileged(ctx))
4040
// Windows privileged sources (WMI thermal zones); no-op off Windows.
4141
out = mergeByName(out, windowsPrivileged(ctx))
42-
// gpuSeparate marks the GPU as its own power rail (a discrete card, or the GB10
43-
// superchip where the Blackwell GPU is metered apart from the Grace CPU). Only
44-
// nvidia-smi reports such a rail here; an integrated GPU (Apple, AMD APU) is
45-
// already inside the CPU package, so its power must not be added to the total.
46-
gpuSeparate := false
4742
if nv, present, err := runNvidiaSMI(ctx); present {
4843
if err != nil {
4944
// nvidia-smi is installed but broke (e.g. driver/library mismatch after
5045
// an un-rebooted driver upgrade) — surface why instead of blanking GPU.
5146
out = mergeByName(out, nvidiaErrorMetrics(nv))
5247
} else {
5348
ms := parseNvidiaSMI(nv)
54-
if hasName(ms, "power.gpu") {
55-
gpuSeparate = true
56-
}
5749
// Unified-memory NVIDIA (GB10 Grace-Blackwell) has no aggregate VRAM
5850
// counter — nvidia-smi memory.* reads [N/A]. Derive gpu.vram from the
5951
// per-process compute-apps memory over the system RAM total.
@@ -73,38 +65,30 @@ func PrivilegedMetrics(ctx context.Context) []domain.Metric {
7365
// only add names not already set, so an NVIDIA or Apple reading is never
7466
// shadowed, and a non-AMD host contributes nothing.
7567
out = mergeByName(out, amdGPU(ctx))
76-
out = withTotalPower(out, runtime.GOOS, gpuSeparate)
68+
out = withTotalPower(out)
7769
out = ensureNPUUtil(out)
7870
if !hasOK(out) {
7971
return []domain.Metric{
80-
{Name: "power.pkg", Status: domain.StatusUnavailable, Detail: "no power source"},
72+
{Name: "power.cpu", Status: domain.StatusUnavailable, Detail: "no power source"},
8173
{Name: "gpu.util", Status: domain.StatusUnavailable, Detail: "no gpu source"},
8274
}
8375
}
8476
return out
8577
}
8678

87-
// withTotalPower appends a source-aware power.total so the panel can headline
88-
// real whole-machine draw instead of just the CPU package. It must never
89-
// double-count: Apple's power.pkg is SMC PSTR (already whole-system), and an
90-
// integrated GPU is already inside the CPU package — only a separate GPU rail
91-
// (discrete NVIDIA, or the GB10 Grace+Blackwell split) is added.
92-
func withTotalPower(ms []domain.Metric, goos string, gpuSeparate bool) []domain.Metric {
93-
pkg, hasPkg := okGauge(ms, "power.pkg")
94-
gpu, hasGPU := okGauge(ms, "power.gpu")
95-
npu, _ := okGauge(ms, "power.npu")
96-
var total float64
97-
switch {
98-
case goos == "darwin":
99-
total = pkg // SMC PSTR is already whole-system
100-
case gpuSeparate:
101-
total = pkg + gpu + npu // CPU package + a discrete/superchip GPU rail
102-
default:
103-
total = pkg // integrated GPU is already counted inside the package
104-
if !hasPkg && hasGPU {
105-
total = gpu
106-
}
79+
// withTotalPower appends the whole-machine power.total so the panel can headline
80+
// real draw. On Apple it is already provided directly (SMC PSTR is whole-system),
81+
// so this leaves it untouched. Everywhere else power.cpu (the RAPL package) and
82+
// power.gpu are physically separate rails — the same split btop/top show — so the
83+
// total is their sum plus any NPU rail.
84+
func withTotalPower(ms []domain.Metric) []domain.Metric {
85+
if hasName(ms, "power.total") {
86+
return ms
10787
}
88+
cpu, _ := okGauge(ms, "power.cpu")
89+
gpu, _ := okGauge(ms, "power.gpu")
90+
npu, _ := okGauge(ms, "power.npu")
91+
total := cpu + gpu + npu
10892
if total <= 0 {
10993
return ms
11094
}
@@ -138,11 +122,11 @@ func powerMetric(name string, w float64) domain.Metric {
138122

139123
// assembleApplePower builds the macOS power/util metrics from the available
140124
// sources. Per-domain CPU/GPU/ANE power and GPU utilisation come from the
141-
// IOReport energy counters when present. Package power has a strict precedence:
142-
// SMC PSTR ("System Total Power") first, then powermetrics, then the IOReport
143-
// per-domain sum as a last resort. The ordering matters: on Apple Silicon
144-
// Pro/Max chips IOReport reports 0 for CPU/ANE and only a sub-watt GPU figure,
145-
// so the energy-sum is a phantom — it must never shadow a real SMC or
125+
// IOReport energy counters when present. The whole-system total has a strict
126+
// precedence: SMC PSTR ("System Total Power") first, then powermetrics, then the
127+
// IOReport per-domain sum as a last resort. The ordering matters: on Apple
128+
// Silicon Pro/Max chips IOReport reports 0 for CPU/ANE and only a sub-watt GPU
129+
// figure, so the energy-sum is a phantom — it must never shadow a real SMC or
146130
// powermetrics reading. pm is the parsed powermetrics output (may be nil).
147131
func assembleApplePower(cpu, gpu, ane, gpuUtil float64, ioOK bool, smcPkg float64, smcOK bool, pm []domain.Metric) []domain.Metric {
148132
var out []domain.Metric
@@ -160,16 +144,16 @@ func assembleApplePower(cpu, gpu, ane, gpuUtil float64, ioOK bool, smcPkg float6
160144
out = append(out, domain.Metric{Name: "gpu.util", Unit: "percent", Status: domain.StatusOK, Gauge: gpuUtil})
161145
}
162146
}
163-
// SMC PSTR is the authoritative whole-system figure and needs no root.
147+
// SMC PSTR is the authoritative whole-system total and needs no root.
164148
if smcOK && smcPkg > 0 {
165-
out = append(out, powerMetric("power.pkg", smcPkg))
149+
out = append(out, powerMetric("power.total", smcPkg))
166150
}
167-
// powermetrics fills any name not already set (package when SMC is absent,
151+
// powermetrics fills any name not already set (the total when SMC is absent,
168152
// plus CPU/GPU/util gaps).
169153
out = mergeByName(out, pm)
170154
// Last resort: the IOReport per-domain sum, only when nothing better gave a
171-
// package figure.
172-
if !hasName(out, "power.pkg") {
155+
// whole-system figure.
156+
if !hasName(out, "power.total") {
173157
sum := 0.0
174158
if ioOK {
175159
for _, w := range []float64{cpu, gpu, ane} {
@@ -179,7 +163,7 @@ func assembleApplePower(cpu, gpu, ane, gpuUtil float64, ioOK bool, smcPkg float6
179163
}
180164
}
181165
if sum > 0 {
182-
out = append(out, powerMetric("power.pkg", sum))
166+
out = append(out, powerMetric("power.total", sum))
183167
}
184168
}
185169
// Apple Silicon is unified memory — there is no discrete VRAM to read. Report

app/internal/helper/collect_linux.go

Lines changed: 8 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -9,85 +9,31 @@ import (
99
"context"
1010
"os"
1111
"path/filepath"
12-
"strings"
1312
"time"
1413

1514
"heimdall/app/internal/domain"
1615
)
1716

18-
// linuxPrivileged reads CPU power/temperature from RAPL and hwmon. power.pkg is
19-
// the whole CPU package (cores + uncore) and power.cpu is the cores alone — both
20-
// are the CPU socket only; a discrete GPU is a separate rail (power.gpu), which
21-
// is why on a workstation power.pkg can read far below power.gpu. Any source may
22-
// be absent (no powercap, no core subdomain, no trusted sensor); whatever is
23-
// found is returned and the rest is omitted, so an unsupported host degrades to
24-
// the Unavailable fallback rather than failing.
17+
// linuxPrivileged reads CPU power from RAPL and package temperature from hwmon.
18+
// power.cpu is the RAPL package (the whole CPU socket — cores + uncore), which is
19+
// what btop/top report as "CPU power". The GPU is a separate rail (power.gpu) and
20+
// the two are summed into power.total upstream. When the SoC exposes no RAPL at
21+
// all (e.g. GB10 Grace/ARM), power.cpu is reported Unavailable-with-reason rather
22+
// than left blank; a trusted temp sensor is added when present.
2523
func linuxPrivileged(ctx context.Context) []domain.Metric {
2624
var out []domain.Metric
2725
if w, ok := raplPackageWatts(ctx); ok {
28-
out = append(out, domain.Metric{Name: "power.pkg", Unit: "watts", Status: domain.StatusOK, Gauge: w, Detail: "CPU package"})
26+
out = append(out, domain.Metric{Name: "power.cpu", Unit: "watts", Status: domain.StatusOK, Gauge: w, Detail: "CPU package"})
2927
} else if len(raplDomainDirs()) == 0 {
3028
// No powercap tree at all — the SoC exposes no RAPL (e.g. GB10 Grace/ARM).
31-
// Say so rather than leaving CPU power a silent blank.
32-
out = append(out, domain.Metric{Name: "power.pkg", Status: domain.StatusUnavailable, Detail: "no RAPL power sensor (SoC/ARM)"})
33-
}
34-
if w, ok := raplCoreWatts(ctx); ok {
35-
out = append(out, domain.Metric{Name: "power.cpu", Unit: "watts", Status: domain.StatusOK, Gauge: w, Detail: "CPU cores"})
29+
out = append(out, domain.Metric{Name: "power.cpu", Status: domain.StatusUnavailable, Detail: "no RAPL power sensor (SoC/ARM)"})
3630
}
3731
if c, ok := hwmonPackageTemp(); ok {
3832
out = append(out, domain.Metric{Name: "temp.pkg", Unit: "celsius", Status: domain.StatusOK, Gauge: c})
3933
}
4034
return out
4135
}
4236

43-
// isCoreDomain reports whether a RAPL domain name is the CPU-core (pp0) subdomain
44-
// that maps to power.cpu — as opposed to the package, uncore, dram, or psys.
45-
func isCoreDomain(name string) bool {
46-
return strings.TrimSpace(name) == "core"
47-
}
48-
49-
// raplCoreWatts samples the RAPL "core" subdomain to report CPU-core power as
50-
// power.cpu, distinct from the package power.pkg. It is absent on CPUs that do
51-
// not expose the core subdomain, in which case power.cpu simply stays unset.
52-
func raplCoreWatts(ctx context.Context) (float64, bool) {
53-
path, ok := raplCoreEnergyPath()
54-
if !ok {
55-
return 0, false
56-
}
57-
e0, err := readMicrojoules(path)
58-
if err != nil {
59-
return 0, false
60-
}
61-
const window = 200 * time.Millisecond
62-
select {
63-
case <-time.After(window):
64-
case <-ctx.Done():
65-
return 0, false
66-
}
67-
e1, err := readMicrojoules(path)
68-
if err != nil {
69-
return 0, false
70-
}
71-
w := raplWatts(e0, e1, window)
72-
return w, w > 0
73-
}
74-
75-
// raplCoreEnergyPath locates the energy counter of the RAPL "core" subdomain,
76-
// matching by the domain's name file rather than by index.
77-
func raplCoreEnergyPath() (string, bool) {
78-
for _, d := range raplDomainDirs() {
79-
name, err := os.ReadFile(filepath.Join(d, "name"))
80-
if err != nil || !isCoreDomain(string(name)) {
81-
continue
82-
}
83-
ep := filepath.Join(d, "energy_uj")
84-
if _, err := os.Stat(ep); err == nil {
85-
return ep, true
86-
}
87-
}
88-
return "", false
89-
}
90-
9137
// raplDomainDirs returns the RAPL domain directories, preferring the intel-rapl
9238
// tree (present on both Intel and most AMD kernels) and falling back to the
9339
// generic powercap tree.

0 commit comments

Comments
 (0)