diff --git a/codecarbon/external/hardware.py b/codecarbon/external/hardware.py index 9c6d113cc..ecbb82931 100644 --- a/codecarbon/external/hardware.py +++ b/codecarbon/external/hardware.py @@ -4,6 +4,7 @@ import math import re +import threading import time from abc import ABC, abstractmethod from dataclasses import dataclass @@ -207,6 +208,9 @@ def __init__( ): assert tracking_mode in ["machine", "process"] self._power_history: List[Power] = [] + # The monitor scheduler thread appends samples while the measurement + # thread drains them; the lock is only held for the O(1) swap. + self._power_history_lock = threading.Lock() self._output_dir = output_dir self._mode = mode self._model = model @@ -393,12 +397,12 @@ def _get_energy_from_cpus(self, delay: Time) -> Energy: return Energy.from_energy(energy) def total_power(self) -> Power: - self._power_history.append(self._get_power_from_cpus()) - power_history_in_W = [power.W for power in self._power_history] - self._power_history = [] - if not power_history_in_W: - logger.warning("No power samples collected, returning 0 W") - return Power.from_watts(0) + latest_sample = self._get_power_from_cpus() + with self._power_history_lock: + power_history = self._power_history + self._power_history = [] + power_history.append(latest_sample) + power_history_in_W = [power.W for power in power_history] cpu_power = sum(power_history_in_W) / len(power_history_in_W) return Power.from_watts(cpu_power) @@ -432,7 +436,8 @@ def start(self): def monitor_power(self): cpu_power = self._get_power_from_cpus() - self._power_history.append(cpu_power) + with self._power_history_lock: + self._power_history.append(cpu_power) def get_model(self): return self._model diff --git a/tests/test_cpu_load.py b/tests/test_cpu_load.py index 7f6243043..1675f246d 100644 --- a/tests/test_cpu_load.py +++ b/tests/test_cpu_load.py @@ -172,6 +172,58 @@ def test_cpu_total_power_fetches_sample_when_history_is_empty( mocked_get_power_from_cpus.assert_called_once() self.assertEqual(cpu._power_history, []) + @mock.patch( + "codecarbon.external.hardware.CPU._get_power_from_cpus", + return_value=Power.from_watts(1), + ) + def test_cpu_total_power_keeps_samples_added_while_draining( + self, + mocked_get_power_from_cpus, + mocked_is_psutil_available, + mocked_is_powergadget_available, + mocked_is_rapl_available, + ): + """A sample appended by the monitor thread while total_power drains the + history must not be lost (see issue #1315).""" + cpu = CPU.from_utils( + None, MODE_CPU_LOAD, "Intel(R) Core(TM) i7-7600U CPU @ 2.80GHz", 100 + ) + + class ProbedHistory(list): + """Runs the monitor thread's write the first time `total_power` + touches the history, whichever way it reads it.""" + + fired = False + + def _fire(inner): + if not inner.fired: + inner.fired = True + cpu.monitor_power() + + def __iter__(inner): + inner._fire() + return list.__iter__(inner) + + def __len__(inner): + inner._fire() + return list.__len__(inner) + + def __getitem__(inner, index): + inner._fire() + return list.__getitem__(inner, index) + + def copy(inner): + inner._fire() + return list.copy(inner) + + cpu._power_history = ProbedHistory([Power.from_watts(1)]) + + cpu.total_power() + + # The interleaved sample must survive the drain, whatever the drain + # is implemented with. + self.assertEqual(len(cpu._power_history), 1) + @mock.patch( "codecarbon.external.hardware.CPU._get_power_from_cpus", return_value=Power.from_watts(30),