diff --git a/codecarbon/core/powermetrics.py b/codecarbon/core/powermetrics.py index a6f834be6..8ca32b766 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 @@ -141,17 +140,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) -> bool: """ @@ -167,10 +168,9 @@ def _log_values(self) -> bool: # Run the powermetrics command with sudo and capture its output cmd = [ "sudo", - "powermetrics", + self._cli, "-n", str(self._n_points), - "", "--samplers", "cpu_power", "--format", @@ -220,29 +220,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 283ed28ed..6c1234390 100644 --- a/tests/test_powermetrics.py +++ b/tests/test_powermetrics.py @@ -84,7 +84,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 @@ -222,6 +259,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_false_on_non_darwin(self): powermetrics = ApplePowermetrics.__new__(ApplePowermetrics) powermetrics._system = "linux" @@ -235,6 +306,7 @@ def _powermetrics_instance(): powermetrics._n_points = 3 powermetrics._interval = 100 powermetrics._log_file_path = "powermetrics_log.txt" + powermetrics._cli = "powermetrics" return powermetrics def test_log_values_warns_on_nonzero_returncode(self): @@ -267,6 +339,20 @@ def test_log_values_runs_with_a_timeout_and_no_stdin(self): assert process.wait.call_args.kwargs["timeout"] == pytest.approx(5.6) assert mock_popen.call_args.kwargs["stdin"] is subprocess.DEVNULL + def test_log_values_builds_clean_command(self): + powermetrics = self._powermetrics_instance() + process = mock.Mock() + process.wait.return_value = 0 + + with mock.patch( + "codecarbon.core.powermetrics.subprocess.Popen", return_value=process + ) as mock_popen: + powermetrics._log_values() + + cmd = mock_popen.call_args.args[0] + assert "" not in cmd + assert cmd[1] == powermetrics._cli + def test_log_values_warns_on_timeout(self): powermetrics = self._powermetrics_instance() hanging = HangingProcess()