Skip to content
Closed
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
9 changes: 7 additions & 2 deletions codecarbon/core/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
from codecarbon.external.logger import logger


def _round_or_none(value: float | None) -> float | None:
"""Round a coordinate, keeping None when it is unknown."""
return None if value is None else round(value, 1)


def get_datetime_with_timezone():
import arrow

Expand Down Expand Up @@ -242,8 +247,8 @@ def _create_run(self, experiment_id: str):
gpu_count=self.conf.get("gpu_count"),
gpu_model=self.conf.get("gpu_model"),
# Reduce precision for Privacy
longitude=round(self.conf.get("longitude", 0), 1),
latitude=round(self.conf.get("latitude", 0), 1),
longitude=_round_or_none(self.conf.get("longitude")),
latitude=_round_or_none(self.conf.get("latitude")),
region=self.conf.get("region"),
provider=self.conf.get("provider"),
ram_total_size=self.conf.get("ram_total_size"),
Expand Down
52 changes: 25 additions & 27 deletions codecarbon/core/powermetrics.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import math
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 @@ -112,6 +111,7 @@ def __init__(
self._system = sys.platform.lower()
self._n_points = n_points
self._interval = interval
self._warned_missing_samples = set()
self._setup_cli()

def _setup_cli(self) -> None:
Expand Down Expand Up @@ -165,7 +165,7 @@ def _log_values(self) -> None:
)
return

def get_details(self) -> Dict:
def get_details(self) -> dict:
"""
Fetches the CPU Power Details by fetching values from a logged csv file
in _log_values function
Expand All @@ -175,29 +175,27 @@ 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)
if not power_list:
# An empty mean is NaN, and NaN poisons every downstream
# total, so report 0 W instead and make the situation visible.
# get_details() runs every measurement cycle, so warn only
# once per chip part to avoid flooding the log.
if chip_part not in self._warned_missing_samples:
self._warned_missing_samples.add(chip_part)
logger.warning(
f"Powermetrics returned no '{chip_part} Power' sample in "
+ f"{self._log_file_path}, reporting 0 W (warned once)."
)
details[f"{chip_part} Power"] = 0.0
details[f"{chip_part} Energy Delta"] = 0.0
continue
watts = [float(power) / 1000 for power in power_list]
details[f"{chip_part} Power"] = statistics.fmean(watts)
details[f"{chip_part} Energy Delta"] = math.fsum(
(self._interval / 1000) * watt for watt in watts
)
except Exception as e:
logger.info(
f"Unable to read Powermetrics logged file at {self._log_file_path}\n \
Expand Down
33 changes: 33 additions & 0 deletions tests/test_api_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,39 @@ def test_call_api(self):
assert payload["ram_utilization_percent"] == 56.5
assert payload["wue"] == 0.8

def test_create_run_rounds_coordinates(self):
with requests_mock.Mocker() as m:
m.post("http://test.com/runs", json={"id": "run-1"}, status_code=201)
api = ApiClient(
endpoint_url="http://test.com",
experiment_id="exp-1",
conf=conf,
create_run_automatically=False,
)

api._create_run("exp-1")

payload = m.last_request.json()
self.assertEqual(payload["longitude"], -7.6)
self.assertEqual(payload["latitude"], 33.6)

def test_create_run_keeps_unknown_coordinates_null(self):
offline_conf = dict(conf, longitude=None, latitude=None)
with requests_mock.Mocker() as m:
m.post("http://test.com/runs", json={"id": "run-1"}, status_code=201)
api = ApiClient(
endpoint_url="http://test.com",
experiment_id="exp-1",
conf=offline_conf,
create_run_automatically=False,
)

self.assertEqual(api._create_run("exp-1"), "run-1")

payload = m.last_request.json()
self.assertIsNone(payload["longitude"])
self.assertIsNone(payload["latitude"])

def test_check_auth_raises_on_error(self):
with requests_mock.Mocker() as m:
m.get("http://test.com/auth/check", text="bad", status_code=401)
Expand Down
59 changes: 58 additions & 1 deletion tests/test_powermetrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,64 @@ def test_get_details(self, mock_setup, mock_log_values):
)
cpu_details = powermetrics.get_details()

assert cpu_details == expected_details
# Tolerance rather than equality: these are float sums/means, and the
# exact last ulp depends on summation order, not on correctness.
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

@mock.patch("codecarbon.core.powermetrics.ApplePowermetrics._log_values")
@mock.patch("codecarbon.core.powermetrics.ApplePowermetrics._setup_cli")
def test_missing_samples_warns_only_once(
self, mock_setup, mock_log_values, tmp_path
):
"""get_details() runs every cycle, so the warning must not flood the log."""
(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",
)

with mock.patch("codecarbon.core.powermetrics.logger.warning") as mock_warning:
for _ in range(3):
assert powermetrics.get_details()["GPU Power"] == 0.0

mock_warning.assert_called_once()

def test_is_powermetrics_available_returns_false_on_instantiation_error(self):
from codecarbon.core.powermetrics import clear_powermetrics_cache
Expand Down