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
71 changes: 33 additions & 38 deletions codecarbon/core/powermetrics.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
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.core.util import detect_cpu_model, is_mac_arm
from codecarbon.external.logger import logger


Expand Down Expand Up @@ -123,6 +122,7 @@ class ApplePowermetrics:
"""

_osx_silicon_exec = "powermetrics"
_warned_no_cpu_samples = False

def __init__(
self,
Expand All @@ -141,17 +141,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 is_mac_arm(cpu_model):
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:
"""
Expand All @@ -167,10 +169,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",
Expand Down Expand Up @@ -220,29 +221,23 @@ 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
)
if details["CPU Energy Delta"] == 0.0 and not self._warned_no_cpu_samples:
self._warned_no_cpu_samples = True
logger.warning(
"Powermetrics logged no CPU power samples, so CPU power is "
"reported as 0 W. Check that powermetrics can run with sudo "
"without a password: "
"https://docs.codecarbon.io/latest/explanation/methodology/#cpu"
)
except Exception as e:
logger.info(
f"Unable to read Powermetrics logged file at {self._log_file_path}\n \
Expand Down
5 changes: 2 additions & 3 deletions codecarbon/core/resource_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,12 +231,11 @@ def _try_platform_cpu_backend(self) -> bool:
if powermetrics.is_powermetrics_available():
self._setup_powermetrics()
return True
# Intel Mac: powermetrics has no CPU power line there, so it is
# not an option (ApplePowermetrics rejects non-Apple Silicon CPUs).
elif cpu.is_powergadget_available():
self._setup_power_gadget()
return True
elif powermetrics.is_powermetrics_available():
self._setup_powermetrics()
return True
elif is_windows_os():
if windows_emi.is_emi_available():
self._setup_windows_emi()
Expand Down
93 changes: 92 additions & 1 deletion tests/test_powermetrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,49 @@ 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",
)

with mock.patch("codecarbon.core.powermetrics.logger.warning") as warning:
assert powermetrics.get_details() == {
"CPU Power": 0.0,
"CPU Energy Delta": 0.0,
"GPU Power": 0.0,
"GPU Energy Delta": 0.0,
}
powermetrics.get_details()

# THEN the silent 0 W is flagged, once per tracker, not once per measure.
warning.assert_called_once()

@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 @@ -222,6 +264,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"
Expand All @@ -235,6 +311,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):
Expand All @@ -253,6 +330,20 @@ def test_log_values_warns_on_nonzero_returncode(self):
mock_popen.assert_called_once()
mock_warning.assert_called_once()

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_runs_with_a_timeout_and_no_stdin(self):
powermetrics = self._powermetrics_instance()
process = mock.Mock()
Expand Down
7 changes: 4 additions & 3 deletions tests/test_resource_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,8 @@ def test_try_platform_cpu_backend_mac_intel_uses_power_gadget():
mock_power_gadget.assert_called_once_with()


def test_try_platform_cpu_backend_mac_intel_falls_back_to_powermetrics():
def test_try_platform_cpu_backend_mac_intel_does_not_use_powermetrics():
"""Intel powermetrics output has no CPU power line: every sample would be 0."""
tracker = make_tracker()
resource_tracker = ResourceTracker(tracker)

Expand All @@ -360,9 +361,9 @@ def test_try_platform_cpu_backend_mac_intel_falls_back_to_powermetrics():
),
patch.object(resource_tracker, "_setup_powermetrics") as mock_powermetrics,
):
assert resource_tracker._try_platform_cpu_backend() is True
assert resource_tracker._try_platform_cpu_backend() is False

mock_powermetrics.assert_called_once_with()
mock_powermetrics.assert_not_called()


def test_set_cpu_tracking_mac_arm_falls_back_to_powermetrics_when_cpu_load_unavailable():
Expand Down
Loading