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
60 changes: 23 additions & 37 deletions codecarbon/core/powermetrics.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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:
"""
Expand All @@ -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",
Expand Down Expand Up @@ -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 \
Expand Down
91 changes: 90 additions & 1 deletion tests/test_powermetrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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(
Expand All @@ -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")
Expand Down
Loading