From 804298f00164ea0b2c882e96dd2fb1f79014a329 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 12 Aug 2026 16:56:25 +0200 Subject: [PATCH 1/2] fix: lock CPU power history swap The 1 Hz monitor scheduler thread appends to CPU._power_history while the measurement scheduler thread drains it. The read-then-rebind in total_power() is not atomic, so any sample appended between the list comprehension and the rebinding was written to the discarded list and lost, biasing the reported cpu_power. Take the swap under a lock, held only for the O(1) rebinding so a slow _get_power_from_cpus() backend never blocks the monitor thread. Also drop the unreachable empty-history branch: a sample is always appended before the average. Closes #1315 Co-Authored-By: Claude Opus 5 (1M context) --- codecarbon/external/hardware.py | 19 ++++++++++------- tests/test_cpu_load.py | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) 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..04250600c 100644 --- a/tests/test_cpu_load.py +++ b/tests/test_cpu_load.py @@ -172,6 +172,42 @@ 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 InjectingHistory(list): + """Simulates the monitor thread firing while the history is read.""" + + injected = False + + def __iter__(inner): + items = list.__iter__(inner) + for item in items: + yield item + if not inner.injected: + inner.injected = True + cpu.monitor_power() + + cpu._power_history = InjectingHistory([Power.from_watts(1)]) + + cpu.total_power() + + self.assertEqual(len(cpu._power_history), 1) + @mock.patch( "codecarbon.external.hardware.CPU._get_power_from_cpus", return_value=Power.from_watts(30), From d70b9ae5c633513cf71e60f708ba7be482bc55ab Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 12 Aug 2026 19:53:30 +0200 Subject: [PATCH 2/2] test: assert the drain invariant instead of the drain shape --- tests/test_cpu_load.py | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/tests/test_cpu_load.py b/tests/test_cpu_load.py index 04250600c..1675f246d 100644 --- a/tests/test_cpu_load.py +++ b/tests/test_cpu_load.py @@ -189,23 +189,39 @@ def test_cpu_total_power_keeps_samples_added_while_draining( None, MODE_CPU_LOAD, "Intel(R) Core(TM) i7-7600U CPU @ 2.80GHz", 100 ) - class InjectingHistory(list): - """Simulates the monitor thread firing while the history is read.""" + class ProbedHistory(list): + """Runs the monitor thread's write the first time `total_power` + touches the history, whichever way it reads it.""" - injected = False + fired = False - def __iter__(inner): - items = list.__iter__(inner) - for item in items: - yield item - if not inner.injected: - inner.injected = True + def _fire(inner): + if not inner.fired: + inner.fired = True cpu.monitor_power() - cpu._power_history = InjectingHistory([Power.from_watts(1)]) + 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(