Skip to content

Commit 6e2c600

Browse files
committed
fix: show Apple GPU power at idle, document the base-vs-Pro/Max CPU split
A base M4 looked like it "had no GPU" because we dropped a zero-valued GPU rail and the M4's GPU idles at a few mW. The GPU energy channel is always valid on Apple Silicon, so report power.gpu even at ~0W. Keep the >0 gate on CPU/ANE, where 0 genuinely means unavailable on Pro/Max. The CPU asymmetry the fleet showed is real, not our bug: base M-series expose per-domain CPU power via IOReport, Pro/Max/Ultra zero the CPU/ANE channels in both IOReport and powermetrics (verified: CPU Power: 0 mW on an M3 Max). So power.cpu is a real number on base dies and an explained dash on Pro/Max. Wrote it up in guide 13 with the tested SoC + macOS versions.
1 parent 7eeedcb commit 6e2c600

4 files changed

Lines changed: 59 additions & 3 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [2.4.3] - 2026-07-02
11+
12+
### Fixed
13+
- **Apple GPU power is reported at idle instead of dropped.** The collector
14+
skipped a zero-valued GPU rail, so a base M4 — whose GPU idles at a few mW —
15+
looked like it "had no GPU", while a Pro/Max (with a small non-zero idle GPU)
16+
showed one. The GPU energy channel is always valid on Apple Silicon, so
17+
`power.gpu` is now reported even at ~0 W. The CPU asymmetry is *real* and
18+
unchanged: base M-series expose per-domain CPU power, Pro/Max/Ultra do not
19+
(verified — `powermetrics` also reports `CPU Power: 0 mW`), so `power.cpu`
20+
stays Unavailable-with-reason there. Documented in the macOS guide.
21+
1022
## [2.4.2] - 2026-07-01
1123

1224
### Fixed

app/internal/helper/collect.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,15 +143,19 @@ func powerMetric(name string, w float64) domain.Metric {
143143
func assembleApplePower(cpu, gpu, ane, gpuUtil float64, ioOK bool, smcPkg float64, smcOK bool, pm []domain.Metric) []domain.Metric {
144144
var out []domain.Metric
145145
if ioOK {
146+
// CPU and ANE: a 0 reading means the channel is *unavailable* on Pro/Max
147+
// (verified — powermetrics also reports 0 there), so only report a real
148+
// value; the caller fills the Unavailable-with-reason otherwise.
146149
if cpu > 0 {
147150
out = append(out, powerMetric("power.cpu", cpu))
148151
}
149-
if gpu > 0 {
150-
out = append(out, powerMetric("power.gpu", gpu))
151-
}
152152
if ane > 0 {
153153
out = append(out, powerMetric("power.npu", ane))
154154
}
155+
// GPU: the energy channel is always valid on Apple Silicon, so report it
156+
// even at idle (~0 W) rather than dropping the rail — otherwise a base M4,
157+
// whose GPU idles at a few mW, looks like it "has no GPU".
158+
out = append(out, powerMetric("power.gpu", gpu))
155159
if gpuUtil >= 0 {
156160
out = append(out, domain.Metric{Name: "gpu.util", Unit: "percent", Status: domain.StatusOK, Gauge: gpuUtil})
157161
}

app/internal/helper/smc_power_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ func TestAssembleApplePower_SMCWinsOverPhantomIOReport(t *testing.T) {
3030
}
3131
}
3232

33+
// GPU power is always a valid IOReport channel on Apple Silicon, so it must be
34+
// reported even at idle (~0 W) — dropping it made a base M4 look like it "has no
35+
// GPU" while a Pro/Max (whose CPU/ANE channels genuinely read 0) showed the GPU.
36+
func TestAssembleApplePower_ShowsIdleGPU(t *testing.T) {
37+
got := byName(assembleApplePower(0.5, 0 /*gpu idle*/, 0, 3, true /*ioOK*/, 20, true, nil))
38+
if m, ok := got["power.gpu"]; !ok || m.Status != domain.StatusOK || m.Gauge != 0 {
39+
t.Fatalf("power.gpu = %+v (present=%v), want a 0 W reading at idle", m, ok)
40+
}
41+
}
42+
3343
// Without SMC, a sub-watt IOReport sum must not shadow a real powermetrics package
3444
// reading (the old mergeByName poisoning).
3545
func TestAssembleApplePower_PowermetricsBeatsPhantomIOReport(t *testing.T) {

docs/guides/13-privileged-macos.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,36 @@ This ordering exists because of the Pro/Max quirk below.
4747
> counter at all — neither IOReport nor `powermetrics` reports it. `power.cpu`
4848
> reads `unavailable` there. A hardware limit, not a misconfiguration.
4949
50+
## Why a base M4 shows CPU power but an M3 Max doesn't (and both show GPU)
51+
52+
This trips people up, so to be explicit — it's **the chip tier, not the macOS
53+
version, and not a Heimdall bug**:
54+
55+
| Rail | Base (M1–M4) | Pro / Max / Ultra |
56+
|---|---|---|
57+
| `power.cpu` | **reported** — IOReport exposes per-domain CPU energy | **`unavailable`** — the CPU (and ANE) energy channels read **0** |
58+
| `power.gpu` | reported (idles at a few mW) | reported |
59+
| `power.total` | SMC `PSTR`, whole-system | SMC `PSTR`, whole-system |
60+
61+
On **Pro/Max/Ultra** dies the SoC's "Energy Model" simply does not surface
62+
per-domain **CPU** or **ANE** power — both IOReport *and* `powermetrics` return
63+
`CPU Power: 0 mW` even with the cores pegged. So `power.cpu` is honestly
64+
`unavailable` (`Pro/Max: no per-domain CPU power`), while the true whole-machine
65+
draw still comes through `power.total` (SMC). Base dies expose the per-domain CPU
66+
figure, so they show a real `power.cpu`.
67+
68+
**GPU** power is a valid IOReport channel on *every* Apple Silicon SoC, so it is
69+
always reported — including a few milliwatts at idle. (Heimdall used to drop a
70+
zero-valued GPU rail, which made an idle base M4 look like it "had no GPU"; since
71+
v2.4.3 the idle GPU shows as `0 W` instead.)
72+
73+
Verified across two SoCs and macOS builds:
74+
75+
| Machine | SoC | macOS (Darwin) | `power.cpu` | `power.gpu` |
76+
|---|---|---|---|---|
77+
| Mac mini | Apple **M4** (base) | 26.6 / Darwin 25.6 | real (~0.5 W idle) | reported (idle → 0 W) |
78+
| MacBook Pro | Apple **M3 Max** | 27.0 / Darwin 27.0 | `unavailable` (Pro/Max) | reported |
79+
5080
## Build note — IOReport needs CGO
5181

5282
IOReport (and SMC) are reached through cgo. The local `make build-tui` enables

0 commit comments

Comments
 (0)