Apple Silicon energy monitor — per-rail IOReport counters via zeus-apple-silicon.
Reads the SoC's own energy counters (CPU, GPU, DRAM, Apple Neural Engine)
through zeus_apple_silicon, a nanobind extension over IOReport. No root
required, unlike powermetrics.
Why not via zeus-ml: zeus wraps this same library as
zeus.device.soc.apple.AppleSilicon, but that module landed on zeus master
in April 2025 and no zeus release has shipped since February 2025 — every
published zeus-ml (through 0.11.0.post1) has no zeus.device.soc
package at all, and no apple extra. Depending on the underlying library
directly is the only spelling that actually resolves.
The ANE rail is what makes this worth doing: Apple Foundation Models runs
predominantly on the Neural Engine, so a GPU-only reading makes it look
almost free.
Classes
AppleEnergyMonitor
AppleEnergyMonitor(poll_interval_ms: int = 50, allow_estimates: bool = False)
Bases: EnergyMonitor
Apple Silicon energy monitor.
Measures CPU, GPU, DRAM and ANE rails via zeus-apple-silicon. When
that backend is absent, :meth:available reports False so the factory
falls through rather than inventing numbers; a modelled TDP estimate is
reachable only by explicitly constructing with allow_estimates=True.
Source code in src/openjarvis/telemetry/energy_apple.py
| def __init__(
self,
poll_interval_ms: int = 50,
allow_estimates: bool = False,
) -> None:
self._poll_interval_ms = poll_interval_ms
self._allow_estimates = allow_estimates
self._reader: Any = None
self._chip_name, self._tdp_watts = _detect_chip()
# Previous cumulative reading, for deriving power in snapshot().
self._prev: Optional[tuple[float, _Rails]] = None
if _NATIVE_AVAILABLE and platform.system() == "Darwin":
try:
self._reader = _AppleRailReader()
_LIVE_MONITORS.add(self)
except Exception as exc:
logger.debug(
"Failed to initialize Apple Silicon energy monitor: %s",
exc,
)
|
Functions
available
staticmethod
True only when rails can actually be measured.
Deliberately stricter than "is this a Mac": returning True on any
Apple Silicon host is what previously let a modelled estimate
masquerade as a measurement for every reader downstream.
Source code in src/openjarvis/telemetry/energy_apple.py
| @staticmethod
def available() -> bool:
"""True only when rails can actually be *measured*.
Deliberately stricter than "is this a Mac": returning True on any
Apple Silicon host is what previously let a modelled estimate
masquerade as a measurement for every reader downstream.
"""
if not AppleEnergyMonitor.estimate_available():
return False
if not _NATIVE_AVAILABLE:
return False
try:
_AppleRailReader()
except Exception as exc:
logger.debug("Apple Silicon energy backend unusable: %s", exc)
return False
return True
|
estimate_available
staticmethod
estimate_available() -> bool
True on any Apple Silicon host, measurable or not.
Source code in src/openjarvis/telemetry/energy_apple.py
| @staticmethod
def estimate_available() -> bool:
"""True on any Apple Silicon host, measurable or not."""
return platform.system() == "Darwin" and platform.machine() == "arm64"
|
snapshot
Instantaneous reading, for background polling by TelemetrySession.
Energy fields carry the cumulative since-boot counters, which is what
the eval runners difference across a window
(_compute_energy_delta takes last-minus-first). Power is derived
from the delta against the previous snapshot, so the first call in a
session reports zero watts.
Source code in src/openjarvis/telemetry/energy_apple.py
| def snapshot(self) -> EnergySample:
"""Instantaneous reading, for background polling by ``TelemetrySession``.
Energy fields carry the *cumulative* since-boot counters, which is what
the eval runners difference across a window
(``_compute_energy_delta`` takes last-minus-first). Power is derived
from the delta against the previous snapshot, so the first call in a
session reports zero watts.
"""
result = self._new_sample()
if self._reader is None:
return result
try:
metrics = self._reader.get_cumulative_energy()
except Exception as exc:
logger.debug("Apple cumulative energy read failed: %s", exc)
return result
now = time.monotonic()
rails = _rails_from_metrics(metrics)
result.cpu_energy_joules = rails.cpu or 0.0
result.gpu_energy_joules = rails.gpu or 0.0
result.dram_energy_joules = rails.dram or 0.0
result.ane_energy_joules = rails.ane or 0.0
result.soc_energy_joules = rails.total
result.energy_joules = rails.total
result.num_snapshots = 1
prev = self._prev
self._prev = (now, rails)
if prev is not None:
dt = now - prev[0]
if dt > 0:
delta = rails - prev[1]
result.duration_seconds = dt
result.cpu_power_watts = (delta.cpu or 0.0) / dt
result.gpu_power_watts = (delta.gpu or 0.0) / dt
result.ane_power_watts = (delta.ane or 0.0) / dt
result.soc_power_watts = delta.total / dt
result.mean_power_watts = result.soc_power_watts
return result
|