fix: derive Apple Silicon CPU cluster frequency from the pmgr table (#317)
## Summary
CPU frequency metrics read 0 on every Apple Silicon machine because the CPU path never had a frequency table to join its residency histogram against. This adds one, sourced from the same IOKit pmgr node the GPU path already uses.
## Where the value was lost
`IOReportMetrics::process_cpu_channel` in `src/device/macos_native/ioreport.rs`, at the frequency computation itself. Everything upstream and downstream was fine: the `CPU Stats` / `CPU Core Performance States` channels were subscribed correctly, residency samples came back non-empty and with correct values (CPU utilization was always right), the manager averaged correctly, and `cpu_macos.rs` into `api/metrics/cpu.rs` passed the value through untouched. It was 0 by the time it left `process_cpu_channel`.
CPU cluster channels were routed through `calc_freq_from_residencies`, which recovers a clock by parsing the IOReport performance-state **name** as a megahertz integer (`"2064"` -> 2064 MHz). Apple Silicon never names CPU states that way. Captured from the M1 Ultra this was verified on:
```
channel="DIE_0_PCPU_CPU0" states: IDLE, V0P14, V1P13, V2P12, ... V14P0 (15 active)
channel="DIE_0_ECPU_CPU0" states: IDLE, V0P4, V1P3, V2P2, V3P1, V4P0 (5 active)
channel="GPUPH" states: OFF, P1, P2, P3, ... P15
```
Every `parse::<i64>()` on those names failed, so the residency-weighted sum stayed 0 and the average collapsed to 0. Residency was computed on a separate accumulator, which is why utilization stayed correct and only the clock was wrong. The GPU escaped this because `process_gpu_channel` already joined against the IOKit `AppleARMIODevice` pmgr `voltage-states9*` table; `GPUPH`'s `P1..P15` names are just as unparseable. The CPU side simply had no equivalent lookup, which is why `all_smi_gpu_frequency_mhz` read 657 while all three CPU metrics read 0 in the same scrape.
This is pre-existing and not a regression from #312 or the Intel Mac work.
## Root cause and fix
The pmgr node publishes a table per clock domain. On this M1 Ultra:
```
voltage-states1-sram [600, 972, 1332, 1704, 2064] 5 entries = E-cluster active states
voltage-states5-sram [600, 828, 1056, ... 3168, 3228] 15 entries = P-cluster active states
voltage-states9-sram [388, 486, 648, 777, 972, 1296] 6 entries = GPU (already used)
```
`voltage-states*` properties are now loaded once into a shared `PMGR_VOLTAGE_STATES` list that both the GPU and the CPU paths select from. The efficiency cluster reads `voltage-states1-sram`, the performance cluster `voltage-states5-sram`, and selection validates the table length against the channel's active-state count before using it, falling back to a length match across all tables. That fallback is what lets the M5 Super cluster (`MCPU0*`), which has no documented key, resolve at all. `calc_gpu_freq_with_table` is renamed `calc_freq_with_table` since both paths now use it.
Two adjacent hardening changes came out of the same investigation:
- Channel classification strips the `DIE_<n>_` prefix that multi-die packages (M1/M2 Ultra) put on every channel before matching. The old rules covered multi-die `ECPU`/`PCPU` names only by substring accident, and the M5 `MCPU0`/`MCPU1` prefix rules used `starts_with`, so a hypothetical `DIE_0_MCPU0` would have been dropped entirely.
- Table parsing lifts values back over a 32-bit wrap. Clocks above 4.295 GHz do not fit the hertz field, and later Apple Silicon P-cores exceed it. Correction only applies after an entry already near the ceiling, so the non-frequency payloads described below cannot arm it.
The plain `voltage-states1` / `voltage-states5` keys are kept in the lookup order because some chips publish clocks there, but on this machine they hold clock **periods** rather than hertz (`[109226, 79149, 62060, ...]`). They fail the plausibility range and are dropped at load, so they can never be mistaken for a frequency table. There is a regression test pinning exactly that.
## Verification (Apple M1 Ultra, Darwin 25.6.0)
`/metrics` before, from a debug build of `main` at 16b564c:
```
all_smi_gpu_frequency_mhz{gpu="Apple M1 Ultra GPU",...} 657
all_smi_cpu_frequency_mhz{cpu_model="Apple M1 Ultra",...} 0
all_smi_cpu_p_cluster_frequency_mhz{cpu_model="Apple M1 Ultra",...} 0
all_smi_cpu_e_cluster_frequency_mhz{cpu_model="Apple M1 Ultra",...} 0
```
`/metrics` after:
```
all_smi_gpu_frequency_mhz{gpu="Apple M1 Ultra GPU",...} 639
all_smi_cpu_frequency_mhz{cpu_model="Apple M1 Ultra",...} 2646
all_smi_cpu_p_cluster_frequency_mhz{cpu_model="Apple M1 Ultra",...} 3228
all_smi_cpu_e_cluster_frequency_mhz{cpu_model="Apple M1 Ultra",...} 2064
```
Sampled repeatedly over a minute, the values track load and stay inside the hardware limits (P-cluster 3017 to 3228 MHz against a 3228 MHz table maximum, E-cluster 1106 to 2064 MHz against 2064 MHz). `all_smi_cpu_frequency_mhz` is the mean of the two, as `cpu_macos.rs` defines it. GPU frequency is unchanged.
TUI: `all-smi local` under `script` could not be captured because the pty has no window size and `ui/chrome.rs` panics on a zero-width terminal, so the P+E display was verified by driving the renderer directly instead. A temporary test built the real `MacOsCpuReader`, took a live `CpuInfo` off the hardware, and passed it to `print_cpu_info`, the same function the TUI calls. Before, on `main`:
```
p_cluster_frequency_mhz: Some(0), e_cluster_frequency_mhz: Some(0)
CPU Apple M1 Ultra @ cube.loca Arch:arm64 Sockets: 1 Cores:16P+ 4E Freq: 0+0MHz Temp: 56C
```
After:
```
p_cluster_frequency_mhz: Some(2583), e_cluster_frequency_mhz: Some(1195)
CPU Apple M1 Ultra @ cube.loca Arch:arm64 Sockets: 1 Cores:16P+ 4E Freq: 2.58+1.20GHz Temp: 53C
```
The probe was removed before committing; no test file other than `ioreport.rs` is touched.
## Regression tests
13 new tests in `src/device/macos_native/ioreport.rs`, using residency histograms and pmgr tables captured verbatim from the M1 Ultra. `test_cpu_performance_states_are_not_numeric` pins the bug itself: the real P-cluster histogram through `calc_freq_from_residencies` yields 0 MHz with correct residency. `test_p_cluster_frequency_from_real_m1_ultra_sample` and its E-cluster counterpart assert the corrected values (3226 MHz at 72.10% and 1846 MHz at 73.14%) off the same input. The rest cover table parsing (frequency, period rejection, 32-bit wraparound, truncated payload), `DIE_` prefix stripping, cluster classification across single-die, multi-die and M5 naming, and every branch of table selection.
## Test plan
- [x] `cargo test --lib device::macos_native` (49 passed)
- [x] `cargo test --lib device::cpu_macos` (9 passed)
- [x] `cargo test --lib ui::renderers::cpu_renderer` (8 passed)
- [x] `cargo clippy --lib --tests -- -D warnings` (clean)
- [x] `cargo fmt --check` (clean)
- [x] `all-smi api` on Apple M1 Ultra, `/metrics` before and after, captured above
- [x] TUI renderer driven with live hardware `CpuInfo`, before and after, captured above
Closes #314 J
Jeongkyu Shin committed
02b2e6d2da6db2c6b8b98001334dfc0f242821d6
Parent: 16b564c
Committed by GitHub <noreply@github.com>
on 8/5/2026, 9:43:15 AM