Skip to content

Commit dd5c02e

Browse files
committed
feat: source-aware power.total, headline it instead of the CPU package
The POWER panel headlined power.pkg (CPU only), so an rtx-pro pulling 506W on the GPU looked like it was sipping 32W. Add power.total computed where the collector knows the source, so it never double-counts: - Apple: pkg alone (SMC PSTR is already whole-system) - NVIDIA / GB10: pkg + gpu + npu (the GPU is a separate rail) - AMD APU / integrated: pkg alone (the iGPU is inside the package) Panel now shows total / cpu-gpu-npu / CPU pkg, which also makes the cpu (cores) vs pkg (whole socket) distinction explicit. GB10 (Grace/ARM) exposes no RAPL/INA/SoC power sensor at all — checked hwmon, powercap, i2c, nvidia-smi — so power.pkg now reads unavailable with a reason and power.total is the GPU rail alone.
1 parent b8a9b44 commit dd5c02e

6 files changed

Lines changed: 142 additions & 12 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [2.2.9] - 2026-07-01
11+
12+
### Added
13+
- **`power.total` — a source-aware whole-machine power figure**, now headlined by
14+
the top-view POWER panel. The headline used to be `power.pkg` (CPU package
15+
only), so a workstation pulling 506 W on the GPU read as ~32 W. `power.total`
16+
sums the right rails per platform **without double-counting**: Apple =
17+
`power.pkg` (SMC `PSTR` is already whole-system); NVIDIA / GB10 =
18+
`pkg + gpu + npu` (the GPU is a separate rail); AMD APU / integrated = `pkg`
19+
(the iGPU is already inside the package). rtx-pro now reads ~538 W, GB10 ~50 W.
20+
- **`power.pkg` on a no-RAPL SoC now says why.** GB10 (Grace/ARM) exposes no
21+
RAPL, INA, or any CPU/module power sensor, so `power.pkg` reads Unavailable with
22+
`no RAPL power sensor (SoC/ARM)` instead of a silent blank; there `power.total`
23+
is the GPU rail alone.
24+
25+
### Changed
26+
- POWER panel layout: a `total` headline, then `cpu / gpu / npu`, then `CPU pkg`
27+
— making the CPU-cores (`cpu`) vs CPU-package (`pkg`) distinction explicit
28+
(`cpu` is the cores, a subset of the whole `pkg` socket).
29+
1030
## [2.2.8] - 2026-06-30
1131

1232
### Added

app/internal/helper/collect.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,21 @@ 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
4247
if nv, present, err := runNvidiaSMI(ctx); present {
4348
if err != nil {
4449
// nvidia-smi is installed but broke (e.g. driver/library mismatch after
4550
// an un-rebooted driver upgrade) — surface why instead of blanking GPU.
4651
out = mergeByName(out, nvidiaErrorMetrics(nv))
4752
} else {
4853
ms := parseNvidiaSMI(nv)
54+
if hasName(ms, "power.gpu") {
55+
gpuSeparate = true
56+
}
4957
// Unified-memory NVIDIA (GB10 Grace-Blackwell) has no aggregate VRAM
5058
// counter — nvidia-smi memory.* reads [N/A]. Derive gpu.vram from the
5159
// per-process compute-apps memory over the system RAM total.
@@ -65,6 +73,7 @@ func PrivilegedMetrics(ctx context.Context) []domain.Metric {
6573
// only add names not already set, so an NVIDIA or Apple reading is never
6674
// shadowed, and a non-AMD host contributes nothing.
6775
out = mergeByName(out, amdGPU(ctx))
76+
out = withTotalPower(out, runtime.GOOS, gpuSeparate)
6877
out = ensureNPUUtil(out)
6978
if !hasOK(out) {
7079
return []domain.Metric{
@@ -75,6 +84,43 @@ func PrivilegedMetrics(ctx context.Context) []domain.Metric {
7584
return out
7685
}
7786

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+
}
107+
}
108+
if total <= 0 {
109+
return ms
110+
}
111+
return append(ms, domain.Metric{Name: "power.total", Unit: "watts", Status: domain.StatusOK, Gauge: total, Detail: "system total"})
112+
}
113+
114+
// okGauge returns the gauge of an OK metric by name.
115+
func okGauge(ms []domain.Metric, name string) (float64, bool) {
116+
for _, m := range ms {
117+
if m.Name == name && m.Status == domain.StatusOK {
118+
return m.Gauge, true
119+
}
120+
}
121+
return 0, false
122+
}
123+
78124
// ensureNPUUtil guarantees an npu.util reading. NPUs (Apple ANE, Intel AI Boost,
79125
// AMD XDNA) expose no utilisation counter, so if no source set it, report it
80126
// Unavailable-with-reason rather than leaving a bare dash. A reading already set

app/internal/helper/collect_linux.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ func linuxPrivileged(ctx context.Context) []domain.Metric {
2626
var out []domain.Metric
2727
if w, ok := raplPackageWatts(ctx); ok {
2828
out = append(out, domain.Metric{Name: "power.pkg", Unit: "watts", Status: domain.StatusOK, Gauge: w, Detail: "CPU package"})
29+
} else if len(raplDomainDirs()) == 0 {
30+
// 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)"})
2933
}
3034
if w, ok := raplCoreWatts(ctx); ok {
3135
out = append(out, domain.Metric{Name: "power.cpu", Unit: "watts", Status: domain.StatusOK, Gauge: w, Detail: "CPU cores"})
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
// Copyright (C) 2026 Kinn Coelho Juliao <kinncj@gmail.com>
3+
4+
package helper
5+
6+
import (
7+
"testing"
8+
9+
"heimdall/app/internal/domain"
10+
)
11+
12+
// power.total must be source-aware so it never double-counts an integrated GPU
13+
// (already inside the CPU package) nor Apple's SMC PSTR (already whole-system).
14+
func TestWithTotalPower(t *testing.T) {
15+
watts := func(name string, w float64) domain.Metric {
16+
return domain.Metric{Name: name, Unit: "watts", Status: domain.StatusOK, Gauge: w}
17+
}
18+
19+
// Linux + discrete/superchip NVIDIA: CPU package + a separate GPU rail.
20+
got := byName(withTotalPower([]domain.Metric{watts("power.pkg", 32), watts("power.gpu", 506)}, "linux", true))
21+
if m := got["power.total"]; m.Status != domain.StatusOK || m.Gauge != 538 {
22+
t.Errorf("nvidia total = %+v, want 538", m)
23+
}
24+
25+
// GB10 (ARM, no RAPL package): only the GPU rail is measurable.
26+
got = byName(withTotalPower([]domain.Metric{watts("power.gpu", 50)}, "linux", true))
27+
if m := got["power.total"]; m.Status != domain.StatusOK || m.Gauge != 50 {
28+
t.Errorf("gb10 total = %+v, want 50", m)
29+
}
30+
31+
// Apple: power.pkg is SMC PSTR (already whole-system) — do not add the GPU.
32+
got = byName(withTotalPower([]domain.Metric{watts("power.pkg", 20), watts("power.gpu", 5)}, "darwin", false))
33+
if m := got["power.total"]; m.Gauge != 20 {
34+
t.Errorf("apple total = %+v, want 20 (pkg only)", m)
35+
}
36+
37+
// AMD APU (integrated GPU inside the package): total is the package alone.
38+
got = byName(withTotalPower([]domain.Metric{watts("power.pkg", 40), watts("power.gpu", 15)}, "linux", false))
39+
if m := got["power.total"]; m.Gauge != 40 {
40+
t.Errorf("apu total = %+v, want 40 (no double-count)", m)
41+
}
42+
43+
// Nothing to total → no metric rather than a phantom 0.
44+
if _, ok := byName(withTotalPower(nil, "linux", true))["power.total"]; ok {
45+
t.Error("no power sources should yield no power.total")
46+
}
47+
}

app/internal/tui/topview/panels.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -166,24 +166,27 @@ func (m Model) powerPanel(t tier, w int) panelSpec {
166166
cpu := m.numVal("power.cpu", " W", "%.1f")
167167
gpu := m.numVal("power.gpu", " W", "%.1f")
168168
npu := m.numVal("power.npu", " W", "%.1f")
169-
pwrVal := m.numVal("power.pkg", " W", "%.0f")
169+
// Headline the whole-machine total (source-aware, from the collector) so a
170+
// busy GPU isn't hidden behind the much smaller CPU-package figure.
171+
totalVal := m.numVal("power.total", " W", "%.0f")
170172

171173
if t == tierNarrow {
172174
return panelSpec{title: "POWER", lines: []string{
173-
lab("pkg ") + pkg + " " + lab("cpu ") + cpu + " " + lab("gpu ") + gpu + " " + lab("npu ") + npu,
174-
lab("pwr ") + m.sparkW("power.pkg", w) + " " + pwrVal,
175+
lab("cpu ") + cpu + " " + lab("gpu ") + gpu + " " + lab("npu ") + npu,
176+
lab("total ") + m.sparkW("power.total", w) + " " + totalVal,
175177
}}
176178
}
177179
if t == tierMedium {
178180
return panelSpec{title: "POWER", lines: []string{
179-
lab("pkg ") + pkg + " " + lab("cpu ") + cpu + " " + lab("gpu ") + gpu + " " + lab("npu ") + npu,
180-
lab("pwr ") + m.sparkW("power.pkg", w) + " " + pwrVal,
181+
lab("cpu ") + cpu + " " + lab("gpu ") + gpu + " " + lab("npu ") + npu,
182+
lab("total ") + m.sparkW("power.total", w) + " " + totalVal,
183+
lab("CPU pkg ") + pkg,
181184
}}
182185
}
183186
return panelSpec{title: "POWER", lines: []string{
184-
lab("pkg ") + pkg,
187+
lab("total ") + m.sparkW("power.total", w) + " " + totalVal,
185188
lab("cpu ") + cpu + " " + lab("gpu ") + gpu + " " + lab("npu ") + npu,
186-
lab("pwr ") + m.sparkW("power.pkg", w) + " " + pwrVal,
189+
lab("CPU pkg ") + pkg,
187190
}}
188191
}
189192

docs/guides/14-privileged-linux-nvidia.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,21 @@ privileged sources served by `heimdall-helper`:
7777
- **`temp.pkg`** from a trusted hwmon chip.
7878

7979
> **Why `power.pkg` can read *below* `power.gpu`.** Both `power.pkg` and
80-
> `power.cpu` are the **CPU socket only**. A discrete NVIDIA card is a separate
81-
> power rail reported as `power.gpu`, so a workstation happily shows e.g.
82-
> `power.cpu 12W`, `power.pkg 17W`, `power.gpu 61W` — the GPU is not part of the
83-
> CPU package figure. Unlike macOS (where `power.pkg` is SMC `PSTR`, the true
84-
> whole-system total), Linux has no single "whole machine" power counter.
80+
> `power.cpu` are the **CPU socket only** (`power.cpu` is the cores, a subset of
81+
> the whole `power.pkg` package). A discrete NVIDIA card is a separate power rail
82+
> reported as `power.gpu`, so a workstation happily shows e.g. `power.cpu 12W`,
83+
> `power.pkg 17W`, `power.gpu 61W` — the GPU is not part of the CPU package
84+
> figure. **`power.total`** is the source-aware whole-machine sum the top view
85+
> headlines: on a discrete-NVIDIA host it is `pkg + gpu (+ npu)`; on Apple it is
86+
> `pkg` alone (SMC `PSTR` already covers everything); on an AMD APU it is `pkg`
87+
> alone (the iGPU is inside the package).
88+
89+
### GB10 (Grace/ARM) has no CPU power sensor
90+
91+
A GB10 exposes **no** RAPL, INA, or SoC power sensor to the OS — only the GPU
92+
rail via `nvidia-smi`. So `power.pkg` reads `unavailable` (`no RAPL power sensor
93+
(SoC/ARM)`) and `power.total` is the GPU power alone. The Grace CPU / module draw
94+
is simply not measurable from userspace on this platform.
8595
>
8696
> `power.npu` stays `unavailable` on Intel/AMD hosts — their NPUs expose no
8797
> power counter yet, same as Apple's ANE.

0 commit comments

Comments
 (0)