Skip to content

Commit d3264f6

Browse files
committed
feat(macos): read Pro/Max CPU power from raw SMC keys (IOReport reports 0)
On Pro/Max/Ultra the IOReport energy model reports 0 for CPU — verified, powermetrics returns "CPU Power: 0 mW" with the cores pegged — so power.cpu was Unavailable there. But the power is still in the SMC under the per-cluster keys (PC02 + PC42 on an M3 Max, the same source Stats reads). Sum them and report a real power.cpu tagged "P-cores (SMC)" — ~11W under load, verified live on this M3 Max. IOReport stays the first source so base dies (M4) are unchanged; SMC fills Pro/Max; only a chip whose keys we haven't mapped falls back to Unavailable. Generalized the SMC reader to read any float key, and moved the read out of assembleApplePower (injected as a param) so the unit tests stay hardware-free. So Apple didn't screw it — the standard energy APIs just don't surface it on Pro/Max, but the raw SMC does. Guide 13 rewritten to match.
1 parent 6e2c600 commit d3264f6

7 files changed

Lines changed: 131 additions & 59 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [2.5.0] - 2026-07-02
11+
12+
### Added
13+
- **Apple Silicon Pro/Max CPU power, via raw SMC keys.** On Pro/Max/Ultra dies
14+
the IOReport energy model reports `0` for CPU (verified — `powermetrics` does
15+
too), so `power.cpu` used to read Unavailable there. But the per-domain power is
16+
still in the SMC under the per-cluster keys (`PC02` + `PC42` on an M3 Max — the
17+
same source Stats reads). Heimdall now sums those and reports a real `power.cpu`
18+
tagged `P-cores (SMC)`**~11 W under load, verified live on an M3 Max**.
19+
IOReport stays the first source, so base dies (e.g. M4) are unchanged; the SMC
20+
keys fill Pro/Max, and only a chip whose keys aren't mapped yet falls back to
21+
`no per-domain CPU power (IOReport + SMC)`. The SMC reader was generalized to
22+
read any AppleSMC float key.
23+
1024
## [2.4.3] - 2026-07-02
1125

1226
### Fixed

app/internal/helper/collect.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,14 @@ func PrivilegedMetrics(ctx context.Context) []domain.Metric {
2727
if runtime.GOOS == "darwin" {
2828
cpu, gpu, ane, gpuUtil, ioOK := ioReportPower(200)
2929
smcPkg, smcOK := smcSystemPower()
30+
smcCPU, smcCPUOK := smcCPUPower()
3031
// powermetrics (root only) fills GPU utilisation and any power the energy
3132
// counters did not expose.
3233
var pm []domain.Metric
3334
if text, err := runPowermetrics(ctx); err == nil {
3435
pm = parsePowermetrics(text)
3536
}
36-
out = append(out, assembleApplePower(cpu, gpu, ane, gpuUtil, ioOK, smcPkg, smcOK, pm)...)
37+
out = append(out, assembleApplePower(cpu, gpu, ane, gpuUtil, ioOK, smcPkg, smcOK, smcCPU, smcCPUOK, pm)...)
3738
}
3839
// Linux privileged sources (RAPL power, hwmon temps); no-op off Linux.
3940
out = mergeByName(out, linuxPrivileged(ctx))
@@ -140,7 +141,7 @@ func powerMetric(name string, w float64) domain.Metric {
140141
// Silicon Pro/Max chips IOReport reports 0 for CPU/ANE and only a sub-watt GPU
141142
// figure, so the energy-sum is a phantom — it must never shadow a real SMC or
142143
// powermetrics reading. pm is the parsed powermetrics output (may be nil).
143-
func assembleApplePower(cpu, gpu, ane, gpuUtil float64, ioOK bool, smcPkg float64, smcOK bool, pm []domain.Metric) []domain.Metric {
144+
func assembleApplePower(cpu, gpu, ane, gpuUtil float64, ioOK bool, smcPkg float64, smcOK bool, smcCPU float64, smcCPUOK bool, pm []domain.Metric) []domain.Metric {
144145
var out []domain.Metric
145146
if ioOK {
146147
// CPU and ANE: a 0 reading means the channel is *unavailable* on Pro/Max
@@ -182,14 +183,18 @@ func assembleApplePower(cpu, gpu, ane, gpuUtil float64, ioOK bool, smcPkg float6
182183
out = append(out, powerMetric("power.total", sum))
183184
}
184185
}
185-
// Apple Pro/Max SoCs expose no per-domain CPU power — IOReport and powermetrics
186-
// both report 0 for the CPU/ANE channels (only the SMC whole-system total is
187-
// real). Base M-series populate it. Say why rather than leaving power.cpu a
188-
// silent blank; power.total still carries the real whole-machine figure.
186+
// On Pro/Max the IOReport energy model reports 0 for CPU, but the raw SMC
187+
// per-cluster keys still carry it (the source Stats reads) — try those before
188+
// giving up. Base M-series already set power.cpu above from IOReport.
189+
if smcCPUOK && !hasName(out, "power.cpu") {
190+
out = append(out, domain.Metric{Name: "power.cpu", Unit: "watts", Status: domain.StatusOK, Gauge: smcCPU, Detail: "P-cores (SMC)"})
191+
}
192+
// If neither IOReport nor the SMC keys yielded CPU power, say why rather than
193+
// leaving a silent blank; power.total still carries the whole-machine figure.
189194
if ioOK && !hasName(out, "power.cpu") {
190195
out = append(out, domain.Metric{
191196
Name: "power.cpu", Status: domain.StatusUnavailable,
192-
Detail: "Pro/Max: no per-domain CPU power",
197+
Detail: "no per-domain CPU power (IOReport + SMC)",
193198
})
194199
}
195200
// Apple Silicon is unified memory — there is no discrete VRAM to read. Report

app/internal/helper/parse_gpu_vram_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ func TestEnsureNPUUtil(t *testing.T) {
8686
// Apple Silicon has unified memory and no discrete VRAM to report; gpu.vram must
8787
// be Unavailable with a reason rather than absent, so the panel explains the dash.
8888
func TestAssembleApplePower_VRAMUnavailableWithReason(t *testing.T) {
89-
got := byName(assembleApplePower(5, 2, 0, 30, true, 22, true, nil))
89+
got := byName(assembleApplePower(5, 2, 0, 30, true, 22, true, 0, false, nil))
9090
m, ok := got["gpu.vram"]
9191
if !ok || m.Status != domain.StatusUnavailable {
9292
t.Fatalf("gpu.vram = %+v, want unavailable", m)

app/internal/helper/smc_darwin.go

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ package helper
1010
#include <IOKit/IOKitLib.h>
1111
#include <string.h>
1212
#include <stdint.h>
13+
#include <stdlib.h>
1314
1415
// AppleSMC key access. PSTR ("System Total Power", IEEE float, watts) is the
15-
// whole-system power rail mactop/btop/iStat read without root — and the only
16-
// power figure populated on Apple Silicon Pro/Max chips, whose IOReport energy
17-
// channels report 0 for CPU/ANE.
16+
// whole-system power rail mactop/btop/iStat read without root. The per-domain
17+
// keys (PCPC "CPU Package", PCTR "CPU Total", PG0C "GPU", …) carry CPU/GPU power
18+
// even on Apple Silicon Pro/Max, whose IOReport energy channels report 0 — the
19+
// same raw SMC keys Stats reads. All are IEEE floats in watts.
1820
typedef struct { char major, minor, build, reserved[1]; uint16_t release; } smc_vers_t;
1921
typedef struct { uint16_t version, length; uint32_t cpuPLimit, gpuPLimit, memPLimit; } smc_plim_t;
2022
typedef struct { uint32_t dataSize, dataType; char dataAttributes; } smc_kinfo_t;
@@ -35,11 +37,13 @@ static uint32_t smc_str2key(const char *s) {
3537
return ((uint32_t)s[0] << 24) | ((uint32_t)s[1] << 16) | ((uint32_t)s[2] << 8) | (uint32_t)s[3];
3638
}
3739
38-
// smc_read_pstr opens AppleSMC, reads PSTR as a float, and returns watts. The
39-
// connection is opened and closed per call; this runs at the collector cadence
40-
// (hundreds of ms), so the open cost is negligible and we avoid holding a port.
41-
// Returns 0 on any failure (no AppleSMC, key absent, wrong type).
42-
static double smc_read_pstr(void) {
40+
// smc_read_key opens AppleSMC, reads the given 4-char key as a float, and returns
41+
// watts. *found is set to 1 when the key exists and was read as a float (the
42+
// value may legitimately be 0), else 0 — so callers can tell an absent key from a
43+
// genuine zero. The connection is opened/closed per call; at the collector
44+
// cadence the open cost is negligible and we avoid holding a port.
45+
static double smc_read_key(const char *k, int *found) {
46+
*found = 0;
4347
io_service_t svc = IOServiceGetMatchingService(0, IOServiceMatching("AppleSMC"));
4448
if (!svc) return 0;
4549
io_connect_t conn = 0;
@@ -49,7 +53,7 @@ static double smc_read_pstr(void) {
4953
}
5054
IOObjectRelease(svc);
5155
52-
uint32_t key = smc_str2key("PSTR");
56+
uint32_t key = smc_str2key(k);
5357
double watts = 0;
5458
5559
smc_kd_t in = {0}, out = {0};
@@ -73,6 +77,7 @@ static double smc_read_pstr(void) {
7377
float f;
7478
memcpy(&f, ro.bytes, 4);
7579
watts = (double)f;
80+
*found = 1;
7681
}
7782
}
7883
}
@@ -82,13 +87,42 @@ static double smc_read_pstr(void) {
8287
*/
8388
import "C"
8489

85-
// smcSystemPower reads the SMC PSTR ("System Total Power") rail in watts. No root
86-
// required. ok is false when AppleSMC or the key is unavailable, or the reading
87-
// is non-positive, so callers fall back to IOReport / powermetrics.
90+
import "unsafe"
91+
92+
// smcReadFloat reads a 4-char AppleSMC float key in watts. ok is true when the
93+
// key exists and read as a float (value may be 0); false when absent or the wrong
94+
// type. No root required.
95+
func smcReadFloat(key string) (float64, bool) {
96+
ck := C.CString(key)
97+
defer C.free(unsafe.Pointer(ck))
98+
var found C.int
99+
w := float64(C.smc_read_key(ck, &found))
100+
return w, found != 0
101+
}
102+
103+
// smcSystemPower reads the SMC PSTR ("System Total Power") rail in watts. ok is
104+
// false when AppleSMC or the key is unavailable, or the reading is non-positive,
105+
// so callers fall back to IOReport / powermetrics.
88106
func smcSystemPower() (watts float64, ok bool) {
89-
w := float64(C.smc_read_pstr())
90-
if w <= 0 {
107+
w, found := smcReadFloat("PSTR")
108+
if !found || w <= 0 {
91109
return 0, false
92110
}
93111
return w, true
94112
}
113+
114+
// smcCPUPower sums the raw SMC P-core cluster power keys, which carry CPU power
115+
// on Apple Silicon Pro/Max even where the IOReport energy model reports 0. On the
116+
// M3 Max these are PC02 + PC42 (the two 6-core P-clusters) — verified live; the
117+
// aggregate keys (PCPC/PCTR) that exist on some chips are absent there. Returns
118+
// the cluster sum when at least one key is present (0 at idle is a real reading);
119+
// absent on chips that expose none, so the caller falls back to Unavailable.
120+
func smcCPUPower() (watts float64, ok bool) {
121+
for _, k := range []string{"PC02", "PC42"} {
122+
if w, found := smcReadFloat(k); found {
123+
watts += w
124+
ok = true
125+
}
126+
}
127+
return watts, ok
128+
}

app/internal/helper/smc_other.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,7 @@ package helper
88
// smcSystemPower is macOS-only; off Apple Silicon or in CGO-free builds it
99
// reports no value and callers fall back to IOReport / powermetrics.
1010
func smcSystemPower() (watts float64, ok bool) { return 0, false }
11+
12+
// smcCPUPower is macOS-only; off Apple Silicon or in CGO-free builds it reports
13+
// no value.
14+
func smcCPUPower() (watts float64, ok bool) { return 0, false }

app/internal/helper/smc_power_test.go

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313
// only a sub-watt GPU figure. SMC PSTR ("System Total Power") is the real number.
1414
// power.total must come from SMC, not the ~0.1W IOReport sum.
1515
func TestAssembleApplePower_SMCWinsOverPhantomIOReport(t *testing.T) {
16-
got := byName(assembleApplePower(0, 0.1, 0, 5, true /*ioOK*/, 42.0, true /*smcOK*/, nil))
16+
got := byName(assembleApplePower(0, 0.1, 0, 5, true /*ioOK*/, 42.0, true /*smcOK*/, 0, false, nil))
1717
if m := got["power.total"]; m.Status != domain.StatusOK || m.Gauge != 42.0 {
1818
t.Errorf("power.total = %+v, want 42W from SMC", m)
1919
}
@@ -30,11 +30,23 @@ func TestAssembleApplePower_SMCWinsOverPhantomIOReport(t *testing.T) {
3030
}
3131
}
3232

33+
// On Pro/Max IOReport gives no CPU power (0), but the raw SMC P-core cluster keys
34+
// do — power.cpu is filled from SMC then, tagged so its source is clear.
35+
func TestAssembleApplePower_SMCCPUFillsProMax(t *testing.T) {
36+
got := byName(assembleApplePower(0 /*ioreport cpu 0*/, 0.1, 0, 5, true, 42, true, 10.6 /*smcCPU*/, true, nil))
37+
if m := got["power.cpu"]; m.Status != domain.StatusOK || m.Gauge != 10.6 {
38+
t.Fatalf("power.cpu = %+v, want 10.6W from SMC", m)
39+
}
40+
if got["power.cpu"].Detail == "" {
41+
t.Error("SMC-sourced power.cpu should be labelled")
42+
}
43+
}
44+
3345
// GPU power is always a valid IOReport channel on Apple Silicon, so it must be
3446
// reported even at idle (~0 W) — dropping it made a base M4 look like it "has no
3547
// GPU" while a Pro/Max (whose CPU/ANE channels genuinely read 0) showed the GPU.
3648
func TestAssembleApplePower_ShowsIdleGPU(t *testing.T) {
37-
got := byName(assembleApplePower(0.5, 0 /*gpu idle*/, 0, 3, true /*ioOK*/, 20, true, nil))
49+
got := byName(assembleApplePower(0.5, 0 /*gpu idle*/, 0, 3, true /*ioOK*/, 20, true, 0, false, nil))
3850
if m, ok := got["power.gpu"]; !ok || m.Status != domain.StatusOK || m.Gauge != 0 {
3951
t.Fatalf("power.gpu = %+v (present=%v), want a 0 W reading at idle", m, ok)
4052
}
@@ -44,7 +56,7 @@ func TestAssembleApplePower_ShowsIdleGPU(t *testing.T) {
4456
// reading (the old mergeByName poisoning).
4557
func TestAssembleApplePower_PowermetricsBeatsPhantomIOReport(t *testing.T) {
4658
pm := []domain.Metric{{Name: "power.total", Unit: "watts", Status: domain.StatusOK, Gauge: 12.7}}
47-
got := byName(assembleApplePower(0, 0.1, 0, -1, true, 0, false /*no smc*/, pm))
59+
got := byName(assembleApplePower(0, 0.1, 0, -1, true, 0, false /*no smc*/, 0, false, pm))
4860
if m := got["power.total"]; m.Status != domain.StatusOK || m.Gauge != 12.7 {
4961
t.Errorf("power.total = %+v, want 12.7W from powermetrics", m)
5062
}
@@ -53,15 +65,15 @@ func TestAssembleApplePower_PowermetricsBeatsPhantomIOReport(t *testing.T) {
5365
// With neither SMC nor powermetrics, a meaningful IOReport sum is still used as a
5466
// last resort (back-compat for chips that do expose per-domain energy).
5567
func TestAssembleApplePower_IOReportSumLastResort(t *testing.T) {
56-
got := byName(assembleApplePower(8, 3, 0, -1, true, 0, false, nil))
68+
got := byName(assembleApplePower(8, 3, 0, -1, true, 0, false, 0, false, nil))
5769
if m := got["power.total"]; m.Status != domain.StatusOK || m.Gauge != 11 {
5870
t.Errorf("power.total = %+v, want 11W from IOReport sum", m)
5971
}
6072
}
6173

6274
// No sources at all: no power.total emitted (caller renders Unavailable).
6375
func TestAssembleApplePower_NoSources(t *testing.T) {
64-
got := byName(assembleApplePower(0, 0, 0, -1, false, 0, false, nil))
76+
got := byName(assembleApplePower(0, 0, 0, -1, false, 0, false, 0, false, nil))
6577
if _, ok := got["power.total"]; ok {
6678
t.Errorf("power.total should be absent when no source yields a value")
6779
}

docs/guides/13-privileged-macos.md

Lines changed: 35 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -43,39 +43,42 @@ This ordering exists because of the Pro/Max quirk below.
4343
> first is what keeps an M3 Max from reporting 0 W. The variable is the **SoC,
4444
> not the Heimdall version**. See [ADR 0020](../architecture/0020-hlidskjalf-top-view-and-npu-rename.md).
4545
46-
> **CPU package-power gap**: some M-series SoCs expose no CPU package-power
47-
> counter at all — neither IOReport nor `powermetrics` reports it. `power.cpu`
48-
> reads `unavailable` there. A hardware limit, not a misconfiguration.
49-
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` |
46+
> **CPU power on Pro/Max**: IOReport and `powermetrics` report `0` for CPU on
47+
> Pro/Max/Ultra, but the raw SMC per-cluster keys still carry it — Heimdall reads
48+
> those (see the next section). `power.cpu` only stays `unavailable` on a chip
49+
> whose SMC keys aren't mapped yet.
50+
51+
## How CPU/GPU power is read on Apple Silicon (base vs Pro/Max)
52+
53+
Heimdall reads Apple power from two unprivileged sources and layers them:
54+
55+
1. **IOReport** "Energy Model" — per-domain CPU/GPU/ANE. On **base** dies (M1–M4)
56+
this populates `power.cpu`. On **Pro/Max/Ultra** the CPU and ANE channels read
57+
**0** (both IOReport *and* `powermetrics` return `CPU Power: 0 mW` with the
58+
cores pegged) — so IOReport alone can't give CPU power there.
59+
2. **Raw SMC keys** — the same source Stats reads. Pro/Max dies still expose CPU
60+
power under the per-cluster power keys (`PC02` + `PC42` = the two 6-core
61+
P-clusters on an M3 Max), even though the energy model reports 0. When IOReport
62+
gives no CPU figure, Heimdall sums those SMC cluster keys and reports
63+
`power.cpu` tagged `P-cores (SMC)`.
64+
65+
So `power.cpu` is **real on both tiers** now — from IOReport on base dies, from
66+
the SMC cluster keys on Pro/Max. It only falls back to
67+
`no per-domain CPU power (IOReport + SMC)` when *neither* source responds (a chip
68+
whose SMC cluster keys we haven't mapped — the keys are reverse-engineered and can
69+
differ per generation, exactly like the temperature keys). `power.total` (SMC
70+
`PSTR`) always carries the whole-machine draw regardless.
71+
72+
**GPU** power is a valid IOReport channel on every Apple Silicon SoC, so it is
73+
always reported — including a few milliwatts at idle (since v2.4.3 the idle GPU
74+
shows as `0 W` instead of being dropped).
75+
76+
Verified live across two SoCs and macOS builds:
77+
78+
| Machine | SoC | macOS (Darwin) | `power.cpu` source | value |
7679
|---|---|---|---|---|
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 |
80+
| Mac mini | Apple **M4** (base) | 26.6 / Darwin 25.6 | IOReport | ~0.5 W idle |
81+
| MacBook Pro | Apple **M3 Max** | 27.0 / Darwin 27.0 | **SMC `PC02`+`PC42`** | ~11 W under load |
7982

8083
## Build note — IOReport needs CGO
8184

0 commit comments

Comments
 (0)