Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,45 @@ This project follows a Keep a Changelog-style format and uses version tags for r

## [Unreleased]

## [1.9.13] - 2026-10-10

### Fixed

- **GPU DVFS tables on Apple M1 Max are no longer CPU clocks wearing a GPU
label.** `_classify_dvfs_tables` picked buckets by table length alone, and
the M1 Max pmgr domain hands it a 15-entry *P-core* SRAM table
(`voltage-states5-sram`, ceiling 3228 MHz) as "gpu" while the real 7-entry
GPU table (`voltage-states9`, 0-1296 MHz) fell outside the 10-20-entry
window. Every GPU clock, DVFS state name (`P5` resolved to 2676 MHz),
residency histogram, and derived TFLOPS figure was CPU telemetry. M1 Max
now maps its buckets through a hardware-validated override
(`voltage-states1-sram` → E, `voltage-states5-sram` → P, `voltage-states9`
→ GPU); the shape heuristic additionally refuses any GPU candidate whose
max is not strictly below the P-core max, so other SoCs can no longer
mislabel either — an unrecognized chip degrades to the IOAccelerator
utilization fallback instead of publishing false clocks.
- **Memory bandwidth is reported instead of permanently "unavailable".**
The sampler subscribed to `(PMP, "DCS BW", channel startswith "AMCC")`,
but on t8103 the DRAM-pin aggregate is named exactly `RD+WR` (the `AMCC`
totals live under AFR/AFI/AFC BW). The filter matched nothing, so
`bandwidth_available` was False forever and the Mem BW row, chart, alerts,
and JSON field were dead. `RD+WR` is now the headline (GB/s at the DRAM
pins, 400 GB/s class on M1 Max) and the AFR `AMCC RD+WR` fabric aggregate
is exposed as `bandwidth_requested_gbps` — when demand exceeds pinned
traffic the TUI suffixes `[req X]`, showing that L2 is absorbing the gap
(sushi-style LLM decode: ~81-128 GB/s DRAM vs ~107 GB/s fabric, ~30%).

- **CPU power is honest where IOReport never delivers it.** Unprivileged
IOReport reports every `CPU Energy` channel as exactly 0 mJ on this SoC
even under 8-core full load (cross-checked against root `powermetrics`:
32.4 W where actop showed 0.00 W — a fabricated zero indistinguishable
from idle). Root does not help: concurrent-probe experiments show the
counters only advance while a privileged powermetrics stream arms them,
and every solo root run reads 0 mJ. The sampler now records whether any
tick ever saw a nonzero CPU energy delta and exports it as
`cpu_power_available`; the TUI renders the CPU row as `n/a (no data)`
and annotates the package total `(GPU+ANE)` while the rail is blind.

## [1.9.12] - 2026-08-23

### Fixed
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,15 +267,15 @@ No sudo required. Degrades to `Unknown` if the ObjC runtime call fails.

| Signal | Source | Notes |
| --- | --- | --- |
| CPU/GPU/ANE power (W) | IOReport Energy Model | nJ per sample interval → watts |
| CPU/GPU/ANE power (W) | IOReport Energy Model | nJ per sample interval → watts. On some SoCs (M1 Max) the CPU energy rail is never delivered to *any* IOReport client — it reads 0 mJ even as root; the CPU row then renders `n/a (no data)` and the package total is annotated `(GPU+ANE)` — never a fabricated zero (`cpu_power_available`) |
| Per-core frequency (MHz) | IOReport residency + DVFS tables | Weighted average of active P-states |
| Per-core activity (%) | IOReport CPU Core Performance States | Via `CoreSample` (residency-weighted active%) |
| GPU frequency and activity | IOReport GPU Performance States | Weighted average of GPUPH residencies |
| CPU/GPU temperature (°C) | SMC via IOKit ctypes | Max die temp per cluster |
| RAM / swap | Native `ctypes` (`host_statistics64` + `XSWUsage`) | `total - available` for used. API/export carry exact **bytes**; the TUI displays **GiB** (2³⁰). Bandwidth stays decimal `GB/s` — Apple's own unit for the bus |
| SoC profile | `sysctl` brand → 16 M1–M4 profiles | Tier fallbacks for unknown chips |
| Top processes | Native `ctypes` (`proc_listpids`/`proc_pidinfo`) | Optional `--proc-filter` regex |
| Bandwidth | IOReport (when available) | N/A if DCS counters not exposed |
| Bandwidth | IOReport PMP `DCS BW` / `RD+WR` (DRAM pins) + PMP `AFR BW` / `AMCC RD+WR` (fabric demand) | N/A if DCS counters not exposed; TUI suffixes `[req X]` when fabric demand exceeds pinned traffic (cache absorbing the gap) |
| Thermal pressure | `NSProcessInfo.thermalState` via ObjC runtime | Nominal / Fair / Serious / Critical |

## Architecture
Expand Down Expand Up @@ -349,6 +349,12 @@ graph TD
## Troubleshooting

- **Bandwidth shows N/A**: IOReport does not expose memory bandwidth counters on all SoCs.
- **CPU power reads `n/a (no data)`**: on some SoCs (M1 Max among them) the
CPU energy channels read 0 mJ for every IOReport client — including root
under full load; the counters only move while a privileged `powermetrics`
stream is running, and actop does not shell out to it. GPU/ANE rails and
package-relative charts are unaffected. actop deliberately shows `n/a`
rather than a plausible-looking 0 W.
- **Thermal shows "Unknown"**: ObjC runtime failed to read `NSProcessInfo.thermalState` (unexpected on macOS 12+).
- **Frequencies show 0 MHz**: DVFS table discovery failed. File an issue with `sysctl -n machdep.cpu.brand_string` output.
- **Metric differences vs other tools**: expected due to sampling window and source timing differences.
Expand Down
6 changes: 6 additions & 0 deletions actop/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ def _sample_to_snapshot(
# total_gbps is a residency-weighted average already in GB/s — not a
# byte counter, so it is not divided by the sample interval.
total_bw = float(bw.get("total_gbps", 0.0)) if bw_avail else 0.0
requested_bw = float(bw.get("requested_gbps", 0.0)) if bw_avail else 0.0
cpu_watts = cm["cpu_W"] / interval_s
gpu_watts = cm["gpu_W"] / interval_s
ane_watts = cm["ane_W"] / interval_s
Expand Down Expand Up @@ -143,6 +144,11 @@ def _sample_to_snapshot(
thermal_state=sample.thermal_pressure,
bandwidth_gbps=total_bw,
bandwidth_available=bw_avail,
bandwidth_requested_gbps=requested_bw,
# Unprivileged IOReport reports every CPU energy channel as 0 mJ on
# some SoCs; propagate the sampler's "ever saw a nonzero delta" flag
# so consumers render n/a instead of a fabricated 0 W.
cpu_power_available=bool(cm.get("cpu_power_available", True)),
fans=list(sample.fans),
fan_rpms=[f.current for f in sample.fans],
fan_available=sample.fan_available,
Expand Down
9 changes: 9 additions & 0 deletions actop/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@
# Bandwidth stays decimal GB/s: that is Apple's own unit for the bus (546
# GB/s on M4 Max) and the DCS bucket labels are literally "32GB/s".
("bandwidth_gbps", "memory_bandwidth_gbps"),
# Fabric-side demand aggregate (PMP/AFR BW "AMCC RD+WR"): what the
# memory controllers were asked to move, vs. bandwidth_gbps which is
# what the DRAM pins actually moved.
("bandwidth_requested_gbps", "memory_bandwidth_requested_gbps"),
# 1 = a real CPU-energy delta was observed at least once this session;
# 0 = the unprivileged-IOReport blind spot on some SoCs, where
# cpu_power_watts reads 0 and must be treated as unknown (powermetrics
# as root is the only true CPU-power source there), not as idle.
("cpu_power_available", "cpu_power_available"),
# Network / disk I/O rates in bytes/s (canonical base unit).
("net_rx_bps", "network_receive_bytes_per_second"),
("net_tx_bps", "network_transmit_bytes_per_second"),
Expand Down
11 changes: 11 additions & 0 deletions actop/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,14 @@ class SystemSnapshot:
ecpu_residency_pct: dict = field(default_factory=_default_residency)
pcpu_residency_pct: dict = field(default_factory=_default_residency)
gpu_residency_pct: dict = field(default_factory=_default_residency)
# Fabric-side bandwidth demand (PMP/AFR BW "AMCC RD+WR" aggregate), GB/s.
# What the memory controllers were asked to move, vs. bandwidth_gbps
# (above) which is what the DRAM pins actually moved. 0.0 when the
# channel is absent; only meaningful when bandwidth_available is True.
bandwidth_requested_gbps: float = 0.0
# False while every CPU energy delta has read zero. On some SoCs (e.g.
# t8103/M1 Max) the "CPU Energy" channels are never delivered to any
# IOReport client — 0 mJ even as root under full load; only privileged
# powermetrics sees the rail. Consumers MUST render cpu_watts as
# unavailable, never as a measured 0 W.
cpu_power_available: bool = True
56 changes: 49 additions & 7 deletions actop/native_sys.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,15 +406,56 @@ def get_dvfs_tables_native() -> dict:

_cf.CFRelease(props_ref)

# SoC-specific pmgr key overrides win over the shape heuristic: on
# t8103 (M1 Max) the heuristic mis-assigns a *second P-core* table
# (voltage-states5-sram, 15 entries, 3228 MHz ceiling) as the GPU table
# because it is the first 10-20-entry table the CPU picks do not claim.
# The real GPU domain table is voltage-states9: [0, 388, 486, 648, 777,
# 972, 1296] — validated on hardware against powermetrics and the
# 10.4 TFLOPS @ 1293 MHz silicon spec (the GPU cannot clock at CPU rates).
brand = _get_cpu_brand()
override = _DVFS_TABLE_OVERRIDES.get(brand)
if override and all(k in tables for k in override.values()):
return {bucket: tables[key] for bucket, key in override.items()}

return _classify_dvfs_tables(tables)


# bucket -> pmgr voltage-states key, per SoC brand string. Only chips whose
# tables were read from that exact machine belong here; the heuristic below
# (and the api.py ioaccelerator fallback) covers everything else.
_DVFS_TABLE_OVERRIDES = {
"Apple M1 Max": {
"ecpu": "voltage-states1-sram", # [600, 972, 1332, 1704, 2064]
"pcpu": "voltage-states5-sram", # [600 ... 3228]
"gpu": "voltage-states9", # [0, 388 ... 1296]
},
}


_CPU_BRAND = None


def _get_cpu_brand() -> str:
"""machdep.cpu.brand_string, cached (immutable across uptime)."""
global _CPU_BRAND
if _CPU_BRAND is None:
_CPU_BRAND = get_sysctl_string("machdep.cpu.brand_string") or ""
return _CPU_BRAND


def _classify_dvfs_tables(tables: dict) -> dict:
"""Classify raw voltage-states tables into ecpu/pcpu/gpu buckets.

Heuristics: the P-core table has the highest max frequency and the most
entries; the E-core table is small (5-12 entries); the GPU table has
10-20 entries. Returns {'ecpu': [...], 'pcpu': [...], 'gpu': [...]}.
10-20 entries AND a max strictly below the P-core ceiling (a GPU never
clocks at or above its chip's P-cluster max — the 15-16-entry P-core
SRAM mirror must not be mistaken for a GPU table). When no table passes,
'gpu' stays empty and callers degrade honestly (util falls back to
IOAccelerator stats, frequency reports 0 / unavailable) rather than
publishing CPU clocks as GPU clocks.
Returns {'ecpu': [...], 'pcpu': [...], 'gpu': [...]}.
"""
ecpu = []
pcpu = []
Expand All @@ -441,15 +482,16 @@ def _classify_dvfs_tables(tables: dict) -> dict:
ecpu = freqs
break

# GPU: 10-20 entries, not pcpu or ecpu table, first match
# GPU: 10-20 entries, not pcpu or ecpu, ceiling strictly under P-core max
for key, freqs in candidates:
if key == best_pcpu_key:
if key == best_pcpu_key or freqs is ecpu:
continue
if freqs is ecpu:
if not (10 <= len(freqs) <= 20):
continue
if 10 <= len(freqs) <= 20:
gpu = freqs
break
if not freqs or max(freqs) >= best_pcpu_max:
continue
gpu = freqs
break

return {"ecpu": ecpu, "pcpu": pcpu, "gpu": gpu}

Expand Down
79 changes: 62 additions & 17 deletions actop/sampler.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,15 @@ def __init__(self, interval, subsamples=1):
("Energy Model", None),
("CPU Stats", "CPU Core Performance States"),
("GPU Stats", "GPU Performance States"),
# DRAM bandwidth: PMP/DCS BW exposes per-agent residency
# histograms. We only parse the full-range AMCC total; the
# ~90 other channels are subscribed (group-level) but their
# states are skipped in delta() to hold the idle-CPU budget.
# Memory bandwidth: PMP/DCS BW carries the DRAM-controller
# aggregate histogram (channel "RD+WR" on t8103; the real
# pins). PMP/AFR BW carries the per-memory-controller
# aggregate ("AMCC RD+WR") = traffic *requested* through the
# interconnect. Both subscriptions are group-level; delta()
# skips the ~90 other per-agent channels to hold the
# idle-CPU budget.
("PMP", "DCS BW"),
("PMP", "AFR BW"),
]
)
self._interval = interval
Expand All @@ -59,6 +63,12 @@ def __init__(self, interval, subsamples=1):
self._core_counts = _get_core_counts()
self._dvfs = get_dvfs_tables_native()
self._smc = SMCReader()
# True once any tick showed a nonzero CPU-energy delta. On t8103 no
# IOReport client ever sees one — root reads 0 mJ too, and the
# counters only move while a privileged powermetrics stream is
# arming them (verified 2026-10-10 with concurrent probes), so 0 W
# must never be presented as a measurement.
self._cpu_power_live = False

def sample(self):
if self._subsamples <= 1:
Expand Down Expand Up @@ -283,6 +293,7 @@ def _convert(
gpu_freq_mhz = 0
gpu_active_pct = 0
dram_bw_channels = []
fabric_bw_histogram = None
e_cluster_residency_ns = defaultdict(int)
p_cluster_residency_ns = defaultdict(int)
gpu_state_residencies = []
Expand Down Expand Up @@ -334,16 +345,26 @@ def _convert(
gpu_state_residencies = list(item.state_residencies)

elif item.group == "PMP" and item.subgroup == "DCS BW":
# Total DRAM bandwidth = sum over all AMCC RD+WR instances
# (one per memory-controller die; multi-die SoCs — Ultra —
# expose several). Each instance's histogram is kept as its own
# channel so the per-die averages are *summed*, not pooled into
# one mean (pooling would report a single die's rate as the
# whole-chip total). Per-agent channels (EACC/PACC/AGX/...) are
# skipped in delta() and intentionally not parsed: they hard-cap
# at 32 GB/s and cannot attribute high bandwidth correctly.
if item.channel.startswith("AMCC") and item.channel.endswith("RD+WR"):
# Total DRAM bandwidth = sum over every DRAM-controller
# "RD+WR" aggregate. On t8103 (M1 Max) there is one such
# channel, named exactly "RD+WR"; "AMCC ..." prefixed names
# live under AFR/AFI/AFC BW, not here — filtering for an
# "AMCC" prefix here matched nothing and reported the whole
# domain as unavailable. Per-agent channels (EACC/PACC/AGX)
# are skipped in delta() and intentionally not parsed: they
# hard-cap at 32 GB/s and cannot attribute high bandwidth
# correctly.
if item.channel == "RD+WR" or (
item.channel.startswith("AMCC")
and item.channel.endswith("RD+WR")
):
dram_bw_channels.append(item.state_residencies)
elif item.group == "PMP" and item.subgroup == "AFR BW":
# Fabric-side aggregate of memory-controller traffic — the
# closest available proxy for bandwidth *demanded* (vs. the
# DCS figure, which is what the DRAM pins actually moved).
if item.channel == "AMCC RD+WR":
fabric_bw_histogram = item.state_residencies

# Scale energy to match parsers.py convention:
# parsers.py returns cpu_W = energy_mJ / 1000 = energy_J
Expand All @@ -354,6 +375,8 @@ def _convert(
gpu_e = gpu_energy_j * scale
ane_e = ane_energy_j * scale
package_e = cpu_e + gpu_e + ane_e
if cpu_e > 0.0:
self._cpu_power_live = True

cpu_metrics = {
"E-Cluster_active": 0,
Expand All @@ -379,6 +402,14 @@ def _convert(
"cpu_W": cpu_e,
"gpu_W": gpu_e,
"package_W": package_e,
# Honest CPU-power availability: False while every tick's CPU
# energy delta has been zero. On t8103 the CPU rail is silent to
# every IOReport client including root — privileged powermetrics
# is the only true source. Consumers must render n/a, not 0 W.
"cpu_power_available": self._cpu_power_live,
"bandwidth_requested_gbps": _channel_bandwidth_gbps(
fabric_bw_histogram or []
),
"e_core": [],
"p_core": [],
}
Expand Down Expand Up @@ -429,6 +460,11 @@ def _convert(
total_residency = sum(ns for ch in dram_bw_channels for _name, ns in ch)
bandwidth_metrics = {
"total_gbps": total_gbps,
# Fabric-side demand aggregate (AFR BW "AMCC RD+WR"); may exceed
# total_gbps because request-side counting includes traffic the
# DRAM pins absorbed via cache hits downstream of the controller
# histogram's sampling.
"requested_gbps": _channel_bandwidth_gbps(fabric_bw_histogram or []),
# Available only when a channel exists AND carried residency: a
# present-but-silent channel would otherwise surface a misleading
# 0.0 GB/s instead of hiding the row.
Expand Down Expand Up @@ -696,12 +732,21 @@ def _resolve_state_freq(name, freq_table):
def _keep_states(group, subgroup, channel):
"""Decide whether delta() should extract per-state residencies for a channel.

The PMP/DCS BW group has ~90 channels of 32 buckets each, but we parse only
the AMCC totals. Skipping state extraction for the rest avoids thousands of
per-cycle ctypes round-trips. All non-PMP groups extract states as before.
The PMP bandwidth groups have ~90+ channels of 32 buckets each, but we
parse only the aggregates: DCS BW's "RD+WR" (DRAM pins) and AFR BW's
"AMCC RD+WR" (fabric-side demand). Skipping state extraction for the
rest avoids thousands of per-cycle ctypes round-trips. All non-PMP
groups extract states as before.
"""
if group == "PMP":
return channel.startswith("AMCC")
# Admit exactly the channels _convert parses, keyed on subgroup:
# AFC/AFI "AMCC RD+WR" decoys would otherwise burn histogram
# extraction that nothing reads.
if subgroup == "DCS BW":
return channel in ("RD+WR", "AMCC RD+WR")
if subgroup == "AFR BW":
return channel == "AMCC RD+WR"
return False
return True


Expand Down
9 changes: 7 additions & 2 deletions actop/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,16 @@ def _process_display_name(command, max_len=24):
GPU GPU util% @freq, die °C
ANE Apple Neural Engine util% (estimated) and power
RAM Used / total memory (and swap when active)
Mem BW Unified-memory bandwidth in GB/s (hidden if unavailable)
Mem BW Unified-memory bandwidth in GB/s at the DRAM pins; [req X]
marks fabric-side demand above that (cache absorbing the
difference). Hidden if unavailable
↓ In / ↑ Out Network receive / transmit throughput (Network box)
↓ Read / ↑ Write Disk read / write throughput (Disk box)
CPU / GPU Live CPU/GPU rail power (W) with an inline sparkline
Package Power Total SoC power draw in watts (CPU + GPU + ANE + other rails)
Package Power Total SoC power draw in watts (CPU + GPU + ANE + other
rails); "(GPU+ANE)" and CPU row "n/a (no data)" mark
chips whose CPU rail IOReport never delivers (even root
reads 0; only powermetrics sees it)
idle/low/mid/high DVFS residency: % of time since the last sample spent
idle vs. below 40% / 40-74% / ≥75% of the cluster's max
frequency (not just the instantaneous clock). Hidden with
Expand Down
Loading