From 6955c19aab66e7a495945008fa3e132b8b379a53 Mon Sep 17 00:00:00 2001 From: "patrick.lloret@protonmail.com" Date: Sun, 9 Mar 2025 20:46:26 +0100 Subject: [PATCH] feat(codecarbon) add raspberry support --- codecarbon/core/cpu.py | 7 + codecarbon/core/hardware_cache.py | 17 ++- codecarbon/core/resource_tracker.py | 28 +++- codecarbon/emissions_tracker.py | 18 ++- codecarbon/external/hardware.py | 93 +++++++++++++ tests/test_hardware_cache.py | 34 +++++ tests/test_raspberry.py | 205 ++++++++++++++++++++++++++++ tests/test_resource_tracker.py | 73 ++++++++++ 8 files changed, 471 insertions(+), 4 deletions(-) create mode 100644 tests/test_raspberry.py diff --git a/codecarbon/core/cpu.py b/codecarbon/core/cpu.py index e21c39fd8..a7446078e 100644 --- a/codecarbon/core/cpu.py +++ b/codecarbon/core/cpu.py @@ -248,6 +248,13 @@ def is_psutil_available(): return False +def is_raspberry() -> bool: + """ + Check if raspberry power util is present + """ + return os.path.exists("/usr/bin/vcgencmd") + + class IntelPowerGadget: """ A class to interface with Intel Power Gadget for monitoring CPU power consumption on Windows and (non-Apple Silicon) macOS. diff --git a/codecarbon/core/hardware_cache.py b/codecarbon/core/hardware_cache.py index 3c661fe07..b960b7a82 100644 --- a/codecarbon/core/hardware_cache.py +++ b/codecarbon/core/hardware_cache.py @@ -36,6 +36,7 @@ class HardwareKind(str, Enum): CPU = "cpu" APPLE_CHIP = "apple_chip" GPU = "gpu" + RASPBERRY = "raspberry" _cache_lock = threading.Lock() @@ -108,6 +109,8 @@ def _hardware_kind(hw) -> HardwareKind: return HardwareKind.APPLE_CHIP if name == "GPU": return HardwareKind.GPU + if name == "Raspberry": + return HardwareKind.RASPBERRY raise TypeError(f"Unsupported hardware type for cache: {type(hw)}") @@ -148,11 +151,17 @@ def _spec_from_hardware(hw) -> Dict[str, Any]: if kind == HardwareKind.GPU: gpu_ids = _canonical_gpu_ids(hw.gpu_ids) return {"kind": kind.value, "gpu_ids": list(gpu_ids) if gpu_ids else None} + if kind == HardwareKind.RASPBERRY: + return { + "kind": kind.value, + "model": hw._model, + "chip_part": hw.chip_part, + } raise TypeError(f"Unsupported hardware type for cache: {type(hw)}") def _hardware_from_spec(spec: Dict[str, Any], output_dir: str): - from codecarbon.external.hardware import CPU, GPU, AppleSiliconChip + from codecarbon.external.hardware import CPU, GPU, AppleSiliconChip, Raspberry from codecarbon.external.ram import RAM try: @@ -185,6 +194,12 @@ def _hardware_from_spec(spec: Dict[str, Any], output_dir: str): if kind == HardwareKind.GPU: gpu_ids = _canonical_gpu_ids(spec.get("gpu_ids")) return GPU.from_utils(gpu_ids=list(gpu_ids) if gpu_ids else None) + if kind == HardwareKind.RASPBERRY: + return Raspberry( + output_dir=output_dir, + model=spec["model"], + chip_part=spec["chip_part"], + ) raise ValueError(f"Unknown hardware spec kind: {kind}") diff --git a/codecarbon/core/resource_tracker.py b/codecarbon/core/resource_tracker.py index e20838718..a121d7986 100644 --- a/codecarbon/core/resource_tracker.py +++ b/codecarbon/core/resource_tracker.py @@ -12,7 +12,13 @@ is_mac_os, is_windows_os, ) -from codecarbon.external.hardware import CPU, GPU, MODE_CPU_LOAD, AppleSiliconChip +from codecarbon.external.hardware import ( + CPU, + GPU, + MODE_CPU_LOAD, + AppleSiliconChip, + Raspberry, +) from codecarbon.external.logger import logger from codecarbon.external.ram import RAM @@ -39,7 +45,13 @@ def set_RAM_tracking(self): force_ram_power=self.tracker._force_ram_power, ) self.tracker._conf["ram_total_size"] = ram.machine_memory_GB - self.tracker._hardware: List[Union[RAM, CPU, GPU, AppleSiliconChip]] = [ram] + self.tracker._hardware: List[ + Union[RAM, CPU, GPU, AppleSiliconChip, Raspberry] + ] = [ram] + if cpu.is_raspberry(): + self.tracker._hardware = [ + Raspberry.from_utils(self.tracker._output_dir, chip_part="RAM") + ] def _setup_cpu_load_mode(self, tdp, max_power): """Set up CPU tracking in load mode using psutil.""" @@ -102,6 +114,15 @@ def _setup_rapl(self): self.tracker._conf["cpu_model"] = hardware_cpu.get_model() return True + def _setup_raspberry(self): + """Set up CPU tracking using the Raspberry Pi power interface.""" + logger.info("Tracking CPU via raspberry utils") + self.cpu_tracker = "raspberry" + hardware_cpu = Raspberry.from_utils(self.tracker._output_dir, chip_part="CPU") + self.tracker._hardware.append(hardware_cpu) + self.tracker._conf["cpu_model"] = hardware_cpu.get_model() + return True + def _setup_powermetrics(self): """Set up CPU and GPU tracking using PowerMetrics (Apple Silicon).""" logger.info("Tracking Apple CPU and GPU via PowerMetrics") @@ -223,6 +244,9 @@ def _try_platform_cpu_backend(self) -> bool: if is_linux_os() and cpu.is_rapl_available(): self._setup_rapl() return True + if cpu.is_raspberry(): + self._setup_raspberry() + return True if is_mac_os(): cpu_model = detect_cpu_model() or "" if is_mac_arm(cpu_model): diff --git a/codecarbon/emissions_tracker.py b/codecarbon/emissions_tracker.py index 54e95a9fa..9d88c25ff 100644 --- a/codecarbon/emissions_tracker.py +++ b/codecarbon/emissions_tracker.py @@ -23,7 +23,7 @@ from codecarbon.core.config import get_hierarchical_config, normalize_gpu_ids from codecarbon.core.units import Energy, Power, Time, Water from codecarbon.core.util import count_cpus, count_physical_cpus, suppress -from codecarbon.external.hardware import CPU, GPU, AppleSiliconChip +from codecarbon.external.hardware import CPU, GPU, AppleSiliconChip, Raspberry from codecarbon.external.logger import logger, set_logger_format, set_logger_level from codecarbon.external.ram import RAM from codecarbon.external.scheduler import PeriodicScheduler @@ -1192,6 +1192,7 @@ def _monitor_power(self) -> None: def _do_measurements(self) -> None: for hardware in self._hardware: + logger.info(f"measuring from {hardware=}") h_time = time.perf_counter() # Compute last_duration again for more accuracy last_duration = time.perf_counter() - self._last_measured_time @@ -1236,6 +1237,21 @@ def _do_measurements(self) -> None: f"Energy consumed for RAM : {self._total_ram_energy.kWh:.6f} kWh" + f". RAM Power : {self._ram_power.W} W" ) + elif isinstance(hardware, Raspberry): + if hardware.chip_part == "CPU": + self._total_cpu_energy += energy + self._cpu_power = power + logger.info( + f"Energy consumed for all CPUs : {self._total_cpu_energy.kWh:.6f} kWh" + + f". Total CPU Power : {self._cpu_power.W} W" + ) + elif hardware.chip_part == "RAM": + self._total_ram_energy += energy + self._ram_power = power + logger.info( + f"Energy consumed for RAMs : {self._total_ram_energy.kWh:.6f} kWh" + + f". Total RAM Power : {self._ram_power.W} W" + ) elif isinstance(hardware, AppleSiliconChip): if hardware.chip_part == "CPU": self._total_cpu_energy += energy diff --git a/codecarbon/external/hardware.py b/codecarbon/external/hardware.py index 9c6d113cc..acf2b3891 100644 --- a/codecarbon/external/hardware.py +++ b/codecarbon/external/hardware.py @@ -7,6 +7,7 @@ import time from abc import ABC, abstractmethod from dataclasses import dataclass +from subprocess import run from typing import Dict, Iterable, List, Optional, Tuple import psutil @@ -548,3 +549,95 @@ def from_utils( logger.warning("Could not read AppleSiliconChip model.") return cls(output_dir=output_dir, model=model, chip_part=chip_part) + + +@dataclass +class Raspberry(BaseHardware): + def __init__( + self, + output_dir: str, + model: str, + chip_part: str = "CPU", + ): + if chip_part == "CPU": + self.WANTED_COMPONENTS = ( + "3V7_WL_SW", + "3V3_SYS", + "1V8_SYS", + "1V1_SYS", + "0V8_SW", + "VDD_CORE", + "3V3_DAC", + "3V3_ADC", + "0V8_AON", + ) + elif chip_part == "RAM": + self.WANTED_COMPONENTS = ("DDR_VDD2", "DDR_VDD2", "DDR_VDDQ") + else: + raise Exception("Unknown chip part", chip_part) + + self._output_dir = output_dir + self._model = model + self.chip_part = chip_part + + def __repr__(self) -> str: + return f"Raspberry ({self._model} > {self.chip_part})" + + def _get_power(self) -> Power: + """ """ + measure: Dict = self.get_measure() + return Power.from_watts(measure["power"]) + + def _get_energy(self, delay: Time) -> Energy: + """ + Get Chip part energy deltas + Args: + chip_part (str): Chip part to get power from (Processor, GPU, etc.) + :return: energy in kWh + """ + energy = Energy.from_power_and_time( + power=self._get_power(), time=Time.from_seconds(delay) + ) + return energy + + def total_power(self) -> Power: + return self._get_power() + + def get_model(self): + return self._model + + def get_measure(self): + components = {} + res = run(["vcgencmd", "pmic_read_adc"], capture_output=True) + lines = res.stdout.decode("utf-8").splitlines() + for line in lines: + res = re.search( + "([A-Z_0-9]+)_[VA] (current|volt)\(([0-9]+)\)=([0-9.]+)", # noqa: W605 + line, + ) + component_name, measure_type, idx, value = res.groups() + component = components[component_name] = components.get(component_name, {}) + component[measure_type] = float(value) + pi_power = 0 + + for component_name, component in components.items(): + try: + component["power"] = component["volt"] * component["current"] + if component_name in self.WANTED_COMPONENTS: + pi_power += component["power"] + except Exception: + ... + return { + "power": pi_power, + } + + @classmethod + def from_utils( + cls, output_dir: str, model: Optional[str] = None, chip_part: str = "CPU" + ) -> "Raspberry": + if model is None: + model = detect_cpu_model() + if model is None: + logger.warning("Could not read Raspberry model.") + + return cls(output_dir=output_dir, model=model, chip_part=chip_part) diff --git a/tests/test_hardware_cache.py b/tests/test_hardware_cache.py index 9b80164a1..efcb66fc2 100644 --- a/tests/test_hardware_cache.py +++ b/tests/test_hardware_cache.py @@ -156,6 +156,40 @@ def test_spec_from_hardware_windows_emi_cpu(): assert "rapl_dir" not in spec +def test_hardware_kind_raspberry(): + raspberry_hw = type("Raspberry", (), {})() + assert hardware_cache._hardware_kind(raspberry_hw) == "raspberry" + + +def test_spec_from_hardware_raspberry(): + raspberry_hw = type( + "Raspberry", + (), + {"_model": "Raspberry Pi 5", "chip_part": "CPU"}, + )() + assert hardware_cache._spec_from_hardware(raspberry_hw) == { + "kind": "raspberry", + "model": "Raspberry Pi 5", + "chip_part": "CPU", + } + + +def test_spec_and_rebuild_roundtrip_for_raspberry(): + spec = {"kind": "raspberry", "model": "Raspberry Pi 5", "chip_part": "CPU"} + fake_pi = SimpleNamespace(_model="Raspberry Pi 5") + with patch( + "codecarbon.external.hardware.Raspberry", + return_value=fake_pi, + ) as mock_pi_cls: + rebuilt = hardware_cache._hardware_from_spec(spec, "out") + mock_pi_cls.assert_called_once_with( + output_dir="out", + model="Raspberry Pi 5", + chip_part="CPU", + ) + assert rebuilt._model == "Raspberry Pi 5" + + def test_spec_and_rebuild_roundtrip_for_apple_chip(): spec = {"kind": "apple_chip", "model": "Apple M1", "chip_part": "CPU"} fake_chip = SimpleNamespace(_model="Apple M1") diff --git a/tests/test_raspberry.py b/tests/test_raspberry.py new file mode 100644 index 000000000..e8a1a7411 --- /dev/null +++ b/tests/test_raspberry.py @@ -0,0 +1,205 @@ +"""Tests for the Raspberry Pi power interface (`vcgencmd pmic_read_adc`).""" + +import time +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from codecarbon.core import cpu +from codecarbon.core.units import Energy, Power, Time, Water + +# Shape of `vcgencmd pmic_read_adc`, with round values so the expected power is +# obvious: every rail draws current x volt watts. HDMI has no volt reading and +# EXT5V no current one, as on a real Pi, so both are skipped. +PMIC_OUTPUT = b"""\ + 3V7_WL_SW_A current(0)=1.00000000A + 3V3_SYS_A current(1)=2.00000000A + VDD_CORE_A current(7)=3.00000000A + DDR_VDD2_A current(3)=4.00000000A + HDMI_A current(11)=5.00000000A + 3V7_WL_SW_V volt(16)=1.00000000V + 3V3_SYS_V volt(17)=2.00000000V + VDD_CORE_V volt(23)=3.00000000V + DDR_VDD2_V volt(19)=4.00000000V + EXT5V_V volt(24)=5.00000000V +""" + +# 3V7_WL_SW + 3V3_SYS + VDD_CORE, the wanted CPU rails: 1x1 + 2x2 + 3x3 +CPU_WATTS = 14.0 +# DDR_VDD2, the only wanted memory rail present: 4x4 +RAM_WATTS = 16.0 + + +@pytest.fixture +def raspberry_cls(): + """The Raspberry class the tracker itself uses. + + Other tests drop codecarbon.external.hardware from sys.modules, so the + class must be looked up at call time, not at import time. + """ + from codecarbon.emissions_tracker import Raspberry + + return Raspberry + + +@pytest.fixture +def make_raspberry(raspberry_cls): + def _make(chip_part="CPU"): + return raspberry_cls( + output_dir="out", model="Raspberry Pi 5", chip_part=chip_part + ) + + return _make + + +@pytest.fixture +def hardware_globals(raspberry_cls): + """Module globals of the hardware module that class was defined in.""" + return raspberry_cls.get_measure.__globals__ + + +@pytest.fixture +def mock_vcgencmd(hardware_globals): + """Patch the vcgencmd call, yielding the mock so callers can assert on it.""" + mock_run = MagicMock(return_value=SimpleNamespace(stdout=PMIC_OUTPUT)) + with patch.dict(hardware_globals, {"run": mock_run}): + yield mock_run + + +def test_is_raspberry_detects_vcgencmd(): + with patch("codecarbon.core.cpu.os.path.exists", return_value=True) as mock_exists: + assert cpu.is_raspberry() is True + mock_exists.assert_called_once_with("/usr/bin/vcgencmd") + + +def test_is_raspberry_false_without_vcgencmd(): + with patch("codecarbon.core.cpu.os.path.exists", return_value=False): + assert cpu.is_raspberry() is False + + +def test_cpu_chip_part_wants_core_rails(make_raspberry): + raspberry = make_raspberry("CPU") + assert "VDD_CORE" in raspberry.WANTED_COMPONENTS + assert "DDR_VDD2" not in raspberry.WANTED_COMPONENTS + + +def test_ram_chip_part_wants_memory_rails(make_raspberry): + raspberry = make_raspberry("RAM") + assert "DDR_VDDQ" in raspberry.WANTED_COMPONENTS + assert "VDD_CORE" not in raspberry.WANTED_COMPONENTS + + +def test_unknown_chip_part_is_rejected(make_raspberry): + with pytest.raises(Exception, match="Unknown chip part"): + make_raspberry("GPU") + + +def test_repr_names_model_and_chip_part(make_raspberry): + assert repr(make_raspberry("RAM")) == "Raspberry (Raspberry Pi 5 > RAM)" + + +def test_get_model_returns_detected_model(make_raspberry): + assert make_raspberry().get_model() == "Raspberry Pi 5" + + +def test_get_measure_runs_vcgencmd(make_raspberry, mock_vcgencmd): + make_raspberry().get_measure() + mock_vcgencmd.assert_called_once_with( + ["vcgencmd", "pmic_read_adc"], capture_output=True + ) + + +def test_get_measure_sums_cpu_rails(make_raspberry, mock_vcgencmd): + assert make_raspberry("CPU").get_measure() == {"power": CPU_WATTS} + + +def test_get_measure_sums_memory_rails(make_raspberry, mock_vcgencmd): + assert make_raspberry("RAM").get_measure() == {"power": RAM_WATTS} + + +def test_total_power_reads_the_pmic(make_raspberry, mock_vcgencmd): + assert make_raspberry().total_power() == Power.from_watts(CPU_WATTS) + + +def test_get_energy_scales_power_over_time(make_raspberry, mock_vcgencmd): + energy = make_raspberry()._get_energy(Time.from_seconds(3600).seconds) + assert energy.kWh == pytest.approx(CPU_WATTS / 1000) + + +def test_measure_power_and_energy_over_a_duration(make_raspberry, mock_vcgencmd): + power, energy = make_raspberry().measure_power_and_energy(last_duration=3600) + assert power == Power.from_watts(CPU_WATTS) + assert energy.kWh == pytest.approx(CPU_WATTS / 1000) + + +def test_from_utils_detects_the_model(raspberry_cls, hardware_globals): + with patch.dict(hardware_globals, {"detect_cpu_model": lambda: "Raspberry Pi 5"}): + raspberry = raspberry_cls.from_utils("out") + assert raspberry.get_model() == "Raspberry Pi 5" + assert raspberry.chip_part == "CPU" + assert raspberry._output_dir == "out" + + +def test_from_utils_keeps_an_explicit_model(raspberry_cls, hardware_globals): + mock_detect = MagicMock() + with patch.dict(hardware_globals, {"detect_cpu_model": mock_detect}): + raspberry = raspberry_cls.from_utils( + "out", model="Raspberry Pi 4", chip_part="RAM" + ) + mock_detect.assert_not_called() + assert raspberry.get_model() == "Raspberry Pi 4" + assert raspberry.chip_part == "RAM" + + +def test_from_utils_warns_when_the_model_is_unknown(raspberry_cls, hardware_globals): + mock_logger = MagicMock() + with patch.dict( + hardware_globals, + {"detect_cpu_model": lambda: None, "logger": mock_logger}, + ): + raspberry = raspberry_cls.from_utils("out") + assert raspberry.get_model() is None + mock_logger.warning.assert_called_once() + + +def make_measurement_state(hardware): + """Minimal tracker state for BaseEmissionsTracker._do_measurements.""" + return SimpleNamespace( + _hardware=[hardware], + _last_measured_time=time.perf_counter() - 3600, + _pue=1.0, + _wue=0.0, + _total_energy=Energy.from_energy(kWh=0), + _total_water=Water.from_litres(litres=0), + _total_cpu_energy=Energy.from_energy(kWh=0), + _total_ram_energy=Energy.from_energy(kWh=0), + _cpu_power=Power.from_watts(0), + _ram_power=Power.from_watts(0), + _power_measurement_count=0, + ) + + +def test_do_measurements_credits_cpu_energy(make_raspberry, mock_vcgencmd): + from codecarbon.emissions_tracker import BaseEmissionsTracker + + tracker = make_measurement_state(make_raspberry("CPU")) + + BaseEmissionsTracker._do_measurements(tracker) + + assert tracker._cpu_power == Power.from_watts(CPU_WATTS) + assert tracker._total_cpu_energy.kWh == pytest.approx(CPU_WATTS / 1000, rel=1e-3) + assert tracker._total_ram_energy.kWh == 0 + assert tracker._power_measurement_count == 1 + + +def test_do_measurements_credits_ram_energy(make_raspberry, mock_vcgencmd): + from codecarbon.emissions_tracker import BaseEmissionsTracker + + tracker = make_measurement_state(make_raspberry("RAM")) + + BaseEmissionsTracker._do_measurements(tracker) + + assert tracker._ram_power == Power.from_watts(RAM_WATTS) + assert tracker._total_ram_energy.kWh == pytest.approx(RAM_WATTS / 1000, rel=1e-3) + assert tracker._total_cpu_energy.kWh == 0 diff --git a/tests/test_resource_tracker.py b/tests/test_resource_tracker.py index 27a7d1b4d..a6f73f2cc 100644 --- a/tests/test_resource_tracker.py +++ b/tests/test_resource_tracker.py @@ -645,3 +645,76 @@ def __bool__(self): ) assert resource_tracker.cpu_tracker == MODE_CPU_LOAD assert tracker._hardware == [hardware_cpu] + + +def test_set_ram_tracking_uses_pmic_on_raspberry(): + tracker = make_tracker() + fake_ram = SimpleNamespace(machine_memory_GB=8.0) + raspberry_ram = MagicMock() + + with ( + patch("codecarbon.core.resource_tracker.RAM", return_value=fake_ram), + patch("codecarbon.core.resource_tracker.cpu.is_raspberry", return_value=True), + patch( + "codecarbon.core.resource_tracker.Raspberry.from_utils", + return_value=raspberry_ram, + ) as mock_from_utils, + ): + resource_tracker = ResourceTracker(tracker) + resource_tracker.set_RAM_tracking() + + mock_from_utils.assert_called_once_with("out", chip_part="RAM") + assert tracker._hardware == [raspberry_ram] + assert tracker._conf["ram_total_size"] == 8.0 + + +def test_setup_raspberry_tracks_cpu(): + tracker = make_tracker() + resource_tracker = ResourceTracker(tracker) + raspberry_cpu = MagicMock() + raspberry_cpu.get_model.return_value = "Raspberry Pi 5" + + with patch( + "codecarbon.core.resource_tracker.Raspberry.from_utils", + return_value=raspberry_cpu, + ) as mock_from_utils: + assert resource_tracker._setup_raspberry() is True + + mock_from_utils.assert_called_once_with("out", chip_part="CPU") + assert resource_tracker.cpu_tracker == "raspberry" + assert tracker._conf["cpu_model"] == "Raspberry Pi 5" + assert tracker._hardware == [raspberry_cpu] + + +def test_platform_cpu_backend_prefers_raspberry_without_rapl(): + resource_tracker = ResourceTracker(make_tracker()) + + with ( + patch("codecarbon.core.resource_tracker.is_linux_os", return_value=True), + patch( + "codecarbon.core.resource_tracker.cpu.is_rapl_available", return_value=False + ), + patch("codecarbon.core.resource_tracker.cpu.is_raspberry", return_value=True), + patch.object(resource_tracker, "_setup_raspberry") as mock_setup, + ): + assert resource_tracker._try_platform_cpu_backend() is True + + mock_setup.assert_called_once_with() + + +def test_platform_cpu_backend_prefers_rapl_over_raspberry(): + resource_tracker = ResourceTracker(make_tracker()) + + with ( + patch("codecarbon.core.resource_tracker.is_linux_os", return_value=True), + patch( + "codecarbon.core.resource_tracker.cpu.is_rapl_available", return_value=True + ), + patch("codecarbon.core.resource_tracker.cpu.is_raspberry", return_value=True), + patch.object(resource_tracker, "_setup_rapl") as mock_rapl, + patch.object(resource_tracker, "_setup_raspberry") as mock_raspberry, + ): + assert resource_tracker._try_platform_cpu_backend() is True + + mock_rapl.assert_called_once_with() + mock_raspberry.assert_not_called()