From c583b1dfeece4720c6509bb85346b0b0cd6f9aae Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Thu, 20 Aug 2026 08:12:54 +0200 Subject: [PATCH] fix(powermetrics): reject unsupported Macs and report 0 W when no samples Two failure modes in the powermetrics backend produced silently wrong totals. `_setup_cli` returned without setting `_cli` and without raising on an Intel Mac, so `ApplePowermetrics()` succeeded there and `ResourceTracker` could register an `AppleSiliconChip` on hardware that has none. It also crashed with `AttributeError` when `detect_cpu_model()` returned None. Both paths now raise `SystemError`, the argv uses the validated `self._cli`, and a stray empty argument is dropped. Averaging an empty sample list returned NaN (numpy) or raised (`statistics.fmean`), and the NaN poisoned every downstream total. Report 0 W instead. This also drops the numpy dependency from this module. Co-Authored-By: Claude Opus 5 (1M context) --- codecarbon/core/powermetrics.py | 60 +++++++++------------- tests/test_powermetrics.py | 91 ++++++++++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 38 deletions(-) diff --git a/codecarbon/core/powermetrics.py b/codecarbon/core/powermetrics.py index bffc19ce4..94314bb29 100644 --- a/codecarbon/core/powermetrics.py +++ b/codecarbon/core/powermetrics.py @@ -1,14 +1,13 @@ import os import re import shutil +import statistics import subprocess import sys import time from functools import lru_cache from typing import Dict -import numpy as np - from codecarbon.core.util import detect_cpu_model from codecarbon.external.logger import logger @@ -118,17 +117,19 @@ def _setup_cli(self) -> None: """ Setup cli command to run Powermetrics """ - if self._system.startswith("darwin"): - cpu_model = detect_cpu_model() - if cpu_model.startswith("Apple"): - if shutil.which(self._osx_silicon_exec): - self._cli = self._osx_silicon_exec - else: - raise FileNotFoundError( - f"Powermetrics executable not found on {self._system}" - ) - else: + if not self._system.startswith("darwin"): raise SystemError("Platform not supported by Powermetrics") + cpu_model = detect_cpu_model() or "" + if not cpu_model.startswith("Apple"): + raise SystemError( + "Powermetrics is only supported on Apple Silicon, " + f"detected CPU: {cpu_model!r}" + ) + if not shutil.which(self._osx_silicon_exec): + raise FileNotFoundError( + f"Powermetrics executable not found on {self._system}" + ) + self._cli = self._osx_silicon_exec def _log_values(self) -> None: """ @@ -140,10 +141,9 @@ def _log_values(self) -> None: # Run the powermetrics command with sudo and capture its output cmd = [ "sudo", - "powermetrics", + self._cli, "-n", str(self._n_points), - "", "--samplers", "cpu_power", "--format", @@ -175,29 +175,15 @@ def get_details(self) -> Dict: try: with open(self._log_file_path) as f: logfile = f.read() - cpu_pattern = r"CPU Power: (\d+) mW" - cpu_power_list = re.findall(cpu_pattern, logfile) - - details["CPU Power"] = np.mean( - [float(power) / 1000 for power in cpu_power_list] - ) - details["CPU Energy Delta"] = np.sum( - [ - (self._interval / 1000) * (float(power) / 1000) - for power in cpu_power_list - ] - ) - gpu_pattern = r"GPU Power: (\d+) mW" - gpu_power_list = re.findall(gpu_pattern, logfile) - details["GPU Power"] = np.mean( - [float(power) / 1000 for power in gpu_power_list] - ) - details["GPU Energy Delta"] = np.sum( - [ - (self._interval / 1000) * (float(power) / 1000) - for power in gpu_power_list - ] - ) + for chip_part in ("CPU", "GPU"): + power_list = re.findall(rf"{chip_part} Power: (\d+) mW", logfile) + watts = [float(power) / 1000 for power in power_list] + details[f"{chip_part} Power"] = ( + statistics.fmean(watts) if watts else 0.0 + ) + details[f"{chip_part} Energy Delta"] = ( + details[f"{chip_part} Power"] * len(watts) * self._interval / 1000 + ) except Exception as e: logger.info( f"Unable to read Powermetrics logged file at {self._log_file_path}\n \ diff --git a/tests/test_powermetrics.py b/tests/test_powermetrics.py index b20f5df2c..aff90fe7a 100644 --- a/tests/test_powermetrics.py +++ b/tests/test_powermetrics.py @@ -71,7 +71,44 @@ def test_get_details(self, mock_setup, mock_log_values): ) cpu_details = powermetrics.get_details() - assert cpu_details == expected_details + assert sorted(cpu_details) == sorted(expected_details) + for key, expected in expected_details.items(): + assert cpu_details[key] == pytest.approx(expected) + + @mock.patch("codecarbon.core.powermetrics.ApplePowermetrics._log_values") + @mock.patch("codecarbon.core.powermetrics.ApplePowermetrics._setup_cli") + def test_get_details_without_samples(self, mock_setup, mock_log_values, tmp_path): + """An empty log must report 0 W, not NaN, which would poison all totals.""" + (tmp_path / "empty_powermetrics_log.txt").write_text("") + powermetrics = ApplePowermetrics( + output_dir=str(tmp_path), + log_file_name="empty_powermetrics_log.txt", + ) + + assert powermetrics.get_details() == { + "CPU Power": 0.0, + "CPU Energy Delta": 0.0, + "GPU Power": 0.0, + "GPU Energy Delta": 0.0, + } + + @mock.patch("codecarbon.core.powermetrics.ApplePowermetrics._log_values") + @mock.patch("codecarbon.core.powermetrics.ApplePowermetrics._setup_cli") + def test_get_details_without_gpu_samples( + self, mock_setup, mock_log_values, tmp_path + ): + """A log with no GPU line must report 0 W for the GPU, not NaN.""" + (tmp_path / "cpu_only_log.txt").write_text("CPU Power: 500 mW\n") + powermetrics = ApplePowermetrics( + output_dir=str(tmp_path), + log_file_name="cpu_only_log.txt", + ) + + details = powermetrics.get_details() + + assert details["CPU Power"] == 0.5 + assert details["GPU Power"] == 0.0 + assert details["GPU Energy Delta"] == 0.0 def test_is_powermetrics_available_returns_false_on_instantiation_error(self): from codecarbon.core.powermetrics import clear_powermetrics_cache @@ -209,6 +246,40 @@ def test_setup_cli_raises_when_binary_missing_on_apple_silicon(self): with pytest.raises(FileNotFoundError): ApplePowermetrics() + def test_setup_cli_raises_on_intel_mac(self): + with ( + mock.patch("codecarbon.core.powermetrics.sys.platform", "darwin"), + mock.patch( + "codecarbon.core.powermetrics.detect_cpu_model", + return_value="Intel(R) Core(TM) i7-9750H", + ), + ): + with pytest.raises(SystemError): + ApplePowermetrics() + + def test_setup_cli_raises_when_cpu_model_unknown(self): + with ( + mock.patch("codecarbon.core.powermetrics.sys.platform", "darwin"), + mock.patch( + "codecarbon.core.powermetrics.detect_cpu_model", return_value=None + ), + ): + with pytest.raises(SystemError): + ApplePowermetrics() + + def test_setup_cli_sets_cli_on_apple_silicon(self): + with ( + mock.patch("codecarbon.core.powermetrics.sys.platform", "darwin"), + mock.patch( + "codecarbon.core.powermetrics.detect_cpu_model", return_value="Apple M2" + ), + mock.patch( + "codecarbon.core.powermetrics.shutil.which", + return_value="/usr/bin/powermetrics", + ), + ): + assert ApplePowermetrics()._cli == "powermetrics" + def test_log_values_returns_none_on_non_darwin(self): powermetrics = ApplePowermetrics.__new__(ApplePowermetrics) powermetrics._system = "linux" @@ -221,6 +292,7 @@ def test_log_values_warns_on_nonzero_returncode(self): powermetrics._n_points = 3 powermetrics._interval = 100 powermetrics._log_file_path = "powermetrics_log.txt" + powermetrics._cli = "powermetrics" with ( mock.patch( @@ -233,6 +305,23 @@ def test_log_values_warns_on_nonzero_returncode(self): mock_call.assert_called_once() mock_warning.assert_called_once() + def test_log_values_builds_clean_command(self): + powermetrics = ApplePowermetrics.__new__(ApplePowermetrics) + powermetrics._system = "darwin" + powermetrics._n_points = 3 + powermetrics._interval = 100 + powermetrics._log_file_path = "powermetrics_log.txt" + powermetrics._cli = "powermetrics" + + with mock.patch( + "codecarbon.core.powermetrics.subprocess.call", return_value=0 + ) as mock_call: + powermetrics._log_values() + + cmd = mock_call.call_args.args[0] + assert "" not in cmd + assert cmd[1] == powermetrics._cli + @mock.patch("codecarbon.core.powermetrics.ApplePowermetrics._log_values") @mock.patch("builtins.open", side_effect=OSError("missing")) @mock.patch("codecarbon.core.powermetrics.ApplePowermetrics._setup_cli")