Skip to content

Commit 7929a4f

Browse files
authored
Merge pull request #1335 from mlco2/fix/cpu-power-history-race
fix: lock CPU power history swap
2 parents b438616 + 235b9ea commit 7929a4f

6 files changed

Lines changed: 362 additions & 88 deletions

File tree

‎codecarbon/core/hardware_cache.py‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,3 @@ def clear_cache() -> None:
247247
mod = sys.modules.get(mod_name)
248248
if mod is not None:
249249
getattr(mod, clear_fn)()
250-
251-
if "codecarbon.external.hardware" in sys.modules:
252-
from codecarbon.external.hardware import clear_cpu_load_prime_cache
253-
254-
clear_cpu_load_prime_cache()

‎codecarbon/emissions_tracker.py‎

Lines changed: 31 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import os
1010
import platform
1111
import re
12+
import threading
1213
import time
1314
import uuid
1415
import warnings
@@ -277,6 +278,8 @@ def _initialize_runtime_state(self) -> None:
277278
self._gpu_utilization_history: List[float] = []
278279
self._ram_utilization_history: List[float] = []
279280
self._ram_used_history: List[float] = []
281+
# The monitor thread appends while the measurement thread clears/averages
282+
self._utilization_history_lock = threading.Lock()
280283
self._total_cpu_energy: Energy = Energy.from_energy(kWh=0)
281284
self._total_gpu_energy: Energy = Energy.from_energy(kWh=0)
282285
self._total_ram_energy: Energy = Energy.from_energy(kWh=0)
@@ -731,11 +734,7 @@ def start(self) -> None:
731734
self._ensure_hardware_ready()
732735
self._last_measured_time = self._start_time = time.perf_counter()
733736

734-
# Clear utilization history for fresh measurements
735-
self._cpu_utilization_history.clear()
736-
self._ram_utilization_history.clear()
737-
self._ram_used_history.clear()
738-
self._gpu_utilization_history.clear()
737+
self._clear_utilization_history()
739738

740739
# Read initial energy for hardware
741740
for hardware in self._hardware:
@@ -792,11 +791,7 @@ def start_task(self, task_name=None) -> None:
792791
task_name += "_" + uuid.uuid4().__str__()
793792
self._last_measured_time = self._start_time = time.perf_counter()
794793

795-
# Clear utilization history for fresh measurements
796-
self._cpu_utilization_history.clear()
797-
self._ram_utilization_history.clear()
798-
self._ram_used_history.clear()
799-
self._gpu_utilization_history.clear()
794+
self._clear_utilization_history()
800795

801796
# Read initial energy for hardware
802797
for hardware in self._hardware:
@@ -1097,25 +1092,15 @@ def _prepare_emissions_data(self) -> EmissionsData:
10971092
emissions=emissions, # kg
10981093
emissions_rate=emissions / duration.seconds, # kg/s
10991094
cpu_utilization_percent=(
1100-
sum(self._cpu_utilization_history) / len(self._cpu_utilization_history)
1101-
if self._cpu_utilization_history
1102-
else 0
1095+
self._mean_utilization(self._cpu_utilization_history)
11031096
),
11041097
gpu_utilization_percent=(
1105-
sum(self._gpu_utilization_history) / len(self._gpu_utilization_history)
1106-
if self._gpu_utilization_history
1107-
else 0
1098+
self._mean_utilization(self._gpu_utilization_history)
11081099
),
11091100
ram_utilization_percent=(
1110-
sum(self._ram_utilization_history) / len(self._ram_utilization_history)
1111-
if self._ram_utilization_history
1112-
else 0
1113-
),
1114-
ram_used_gb=(
1115-
sum(self._ram_used_history) / len(self._ram_used_history)
1116-
if self._ram_used_history
1117-
else 0
1101+
self._mean_utilization(self._ram_utilization_history)
11181102
),
1103+
ram_used_gb=(self._mean_utilization(self._ram_used_history)),
11191104
cpu_power=avg_cpu_power,
11201105
gpu_power=avg_gpu_power,
11211106
ram_power=avg_ram_power,
@@ -1178,6 +1163,17 @@ def _get_cloud_metadata(self) -> CloudMetadata:
11781163
:return: Metadata containing cloud info
11791164
"""
11801165

1166+
def _clear_utilization_history(self) -> None:
1167+
with self._utilization_history_lock:
1168+
self._cpu_utilization_history.clear()
1169+
self._ram_utilization_history.clear()
1170+
self._ram_used_history.clear()
1171+
self._gpu_utilization_history.clear()
1172+
1173+
def _mean_utilization(self, history: List[float]) -> float:
1174+
with self._utilization_history_lock:
1175+
return sum(history) / len(history) if history else 0
1176+
11811177
def _monitor_power(self) -> None:
11821178
"""
11831179
Monitor the power consumption of the hardware.
@@ -1190,12 +1186,13 @@ def _monitor_power(self) -> None:
11901186
hardware.monitor_power()
11911187

11921188
# Collect CPU and RAM utilization metrics
1193-
self._cpu_utilization_history.append(psutil.cpu_percent())
1194-
self._ram_utilization_history.append(psutil.virtual_memory().percent)
1195-
self._ram_used_history.append(psutil.virtual_memory().used / (1024**3))
1189+
cpu_utilization = psutil.cpu_percent()
1190+
ram_utilization = psutil.virtual_memory().percent
1191+
ram_used = psutil.virtual_memory().used / (1024**3)
11961192

11971193
# Collect GPU utilization metrics (lightweight path — skips
11981194
# heavyweight calls like process lists, memory, temperature).
1195+
gpu_utilizations = []
11991196
for hardware in self._hardware:
12001197
if isinstance(hardware, GPU):
12011198
gpu_ids_to_monitor = hardware.gpu_ids
@@ -1206,9 +1203,13 @@ def _monitor_power(self) -> None:
12061203
and resolved_gpu_index in gpu_ids_to_monitor
12071204
and "gpu_utilization" in gpu_detail
12081205
):
1209-
self._gpu_utilization_history.append(
1210-
gpu_detail["gpu_utilization"]
1211-
)
1206+
gpu_utilizations.append(gpu_detail["gpu_utilization"])
1207+
1208+
with self._utilization_history_lock:
1209+
self._cpu_utilization_history.append(cpu_utilization)
1210+
self._ram_utilization_history.append(ram_utilization)
1211+
self._ram_used_history.append(ram_used)
1212+
self._gpu_utilization_history.extend(gpu_utilizations)
12121213

12131214
def _do_measurements(self) -> None:
12141215
for hardware in self._hardware:

‎codecarbon/external/hardware.py‎

Lines changed: 108 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import math
66
import re
7+
import threading
78
import time
89
from abc import ABC, abstractmethod
910
from dataclasses import dataclass
@@ -29,13 +30,44 @@
2930

3031
MODE_CPU_LOAD = "cpu_load"
3132

32-
# psutil.cpu_percent blocks on first sample; prime once per process for cpu_load mode.
33-
_cpu_load_percent_primed = False
3433

34+
class SystemCPULoadMeter:
35+
"""
36+
System-wide CPU load since the previous reading, with its own baseline.
3537
36-
def clear_cpu_load_prime_cache() -> None:
37-
global _cpu_load_percent_primed
38-
_cpu_load_percent_primed = False
38+
psutil.cpu_percent(interval=None) keeps a single baseline per process, so any
39+
other caller (like the tracker's utilization monitor) resets it and the next
40+
reading only covers the few milliseconds since that call.
41+
"""
42+
43+
def __init__(self):
44+
self._last_times = None
45+
46+
def reset(self) -> None:
47+
self._last_times = psutil.cpu_times()
48+
49+
def percent(self, prime_interval: float = 0.05) -> float:
50+
if self._last_times is None:
51+
self.reset()
52+
time.sleep(prime_interval)
53+
current = psutil.cpu_times()
54+
previous, self._last_times = self._last_times, current
55+
total_delta = self._total_time(current) - self._total_time(previous)
56+
busy_delta = self._busy_time(current) - self._busy_time(previous)
57+
if total_delta <= 0:
58+
return 0.0
59+
return min(max(busy_delta / total_delta * 100, 0.0), 100.0)
60+
61+
@staticmethod
62+
def _total_time(times) -> float:
63+
total = sum(times)
64+
# On Linux, guest time is already counted in user and nice time.
65+
total -= getattr(times, "guest", 0) + getattr(times, "guest_nice", 0)
66+
return total
67+
68+
@classmethod
69+
def _busy_time(cls, times) -> float:
70+
return cls._total_time(times) - times.idle - getattr(times, "iowait", 0)
3971

4072

4173
@dataclass
@@ -206,7 +238,13 @@ def __init__(
206238
rapl_prefer_psys: bool = False,
207239
):
208240
assert tracking_mode in ["machine", "process"]
209-
self._power_history: List[Power] = []
241+
# (power, seconds covered by that sample) pairs, averaged by total_power
242+
self._power_history: List[Tuple[Power, float]] = []
243+
self._last_sample_time: Optional[float] = None
244+
# Serializes sampling and history access between the monitor thread and
245+
# the measurement thread: sampling mutates state in some modes
246+
# (process tracking deltas, Intel Power Gadget log file).
247+
self._power_history_lock = threading.Lock()
210248
self._output_dir = output_dir
211249
self._mode = mode
212250
self._model = model
@@ -219,8 +257,7 @@ def __init__(
219257
# For process tracking: store last measurement time and CPU times
220258
self._last_measurement_time: Optional[float] = None
221259
self._last_cpu_times: Dict[int, float] = {} # pid -> total cpu time
222-
# First cpu_percent sample blocks briefly; later calls use interval=None.
223-
self._cpu_percent_interval: Optional[float] = 0.05
260+
self._cpu_load_meter = SystemCPULoadMeter()
224261

225262
if self._mode == "intel_power_gadget":
226263
self._intel_interface = IntelPowerGadget(self._output_dir)
@@ -277,11 +314,7 @@ def _get_power_from_cpu_load(self):
277314
"""
278315
if self._tracking_mode == "machine":
279316
tdp = self._tdp
280-
cpu_load = psutil.cpu_percent(
281-
interval=self._cpu_percent_interval, percpu=False
282-
)
283-
if self._cpu_percent_interval is not None:
284-
self._cpu_percent_interval = None
317+
cpu_load = self._cpu_load_meter.percent()
285318
logger.debug(f"CPU load : {self._tdp=} W and {cpu_load:.1f} %")
286319
# Cubic relationship with minimum 10% of TDP
287320
load_factor = 0.1 + 0.9 * ((cpu_load / 100.0) ** 3)
@@ -292,24 +325,7 @@ def _get_power_from_cpu_load(self):
292325
elif self._tracking_mode == "process":
293326
# Use CPU times for accurate process tracking
294327
current_time = time.time()
295-
current_cpu_times: Dict[int, float] = {}
296-
297-
# Get CPU time for main process and all children
298-
try:
299-
processes = [self._process] + self._process.children(recursive=True)
300-
except (psutil.NoSuchProcess, psutil.AccessDenied):
301-
processes = [self._process]
302-
303-
for proc in processes:
304-
try:
305-
cpu_times = proc.cpu_times()
306-
# Total CPU time = user + system time
307-
total_cpu_time = cpu_times.user + cpu_times.system
308-
current_cpu_times[proc.pid] = total_cpu_time
309-
except (psutil.NoSuchProcess, psutil.AccessDenied):
310-
logger.debug(
311-
f"Process {proc.pid} disappeared or access denied when getting CPU times."
312-
)
328+
current_cpu_times = self._get_process_cpu_times()
313329

314330
# Calculate CPU usage based on delta
315331
if self._last_measurement_time is not None:
@@ -351,6 +367,26 @@ def _get_power_from_cpu_load(self):
351367
raise Exception(f"Unknown tracking_mode {self._tracking_mode}")
352368
return Power.from_watts(power)
353369

370+
def _get_process_cpu_times(self) -> Dict[int, float]:
371+
"""
372+
CPU time (user + system) of the tracked process and all its children
373+
"""
374+
current_cpu_times: Dict[int, float] = {}
375+
try:
376+
processes = [self._process] + self._process.children(recursive=True)
377+
except (psutil.NoSuchProcess, psutil.AccessDenied):
378+
processes = [self._process]
379+
380+
for proc in processes:
381+
try:
382+
cpu_times = proc.cpu_times()
383+
current_cpu_times[proc.pid] = cpu_times.user + cpu_times.system
384+
except (psutil.NoSuchProcess, psutil.AccessDenied):
385+
logger.debug(
386+
f"Process {proc.pid} disappeared or access denied when getting CPU times."
387+
)
388+
return current_cpu_times
389+
354390
def _get_power_from_cpus(self) -> Power:
355391
"""
356392
Get CPU power
@@ -392,14 +428,34 @@ def _get_energy_from_cpus(self, delay: Time) -> Energy:
392428
# logger.debug(f"_get_energy_from_cpus - MATCH {metric} : {value}")
393429
return Energy.from_energy(energy)
394430

431+
def _take_power_sample(self) -> Tuple[Power, float]:
432+
"""
433+
Sample the CPU power and how many seconds it covers since the previous
434+
sample. The caller must hold _power_history_lock.
435+
"""
436+
power = self._get_power_from_cpus()
437+
now = time.perf_counter()
438+
duration = 0.0
439+
if self._last_sample_time is not None:
440+
duration = now - self._last_sample_time
441+
self._last_sample_time = now
442+
return power, duration
443+
395444
def total_power(self) -> Power:
396-
self._power_history.append(self._get_power_from_cpus())
397-
power_history_in_W = [power.W for power in self._power_history]
398-
self._power_history = []
399-
if not power_history_in_W:
400-
logger.warning("No power samples collected, returning 0 W")
401-
return Power.from_watts(0)
402-
cpu_power = sum(power_history_in_W) / len(power_history_in_W)
445+
with self._power_history_lock:
446+
latest_sample = self._take_power_sample()
447+
power_history = self._power_history
448+
self._power_history = []
449+
power_history.append(latest_sample)
450+
total_duration = sum(duration for _, duration in power_history)
451+
if total_duration > 0:
452+
# Load-based samples cover consecutive intervals of different lengths
453+
cpu_power = (
454+
sum(power.W * duration for power, duration in power_history)
455+
/ total_duration
456+
)
457+
else:
458+
cpu_power = sum(power.W for power, _ in power_history) / len(power_history)
403459
return Power.from_watts(cpu_power)
404460

405461
def measure_power_and_energy(self, last_duration: float) -> Tuple[Power, Energy]:
@@ -412,27 +468,29 @@ def measure_power_and_energy(self, last_duration: float) -> Tuple[Power, Energy]
412468
return super().measure_power_and_energy(last_duration=last_duration)
413469

414470
def start(self):
415-
global _cpu_load_percent_primed
416471
if self._mode in [
417472
"intel_power_gadget",
418473
"intel_rapl",
419474
"windows_emi",
420475
"apple_powermetrics",
421476
]:
422477
self._intel_interface.start()
423-
# Reset process tracking state for fresh measurements
424-
self._last_measurement_time = None
425-
self._last_cpu_times = {}
426-
if self._mode == MODE_CPU_LOAD:
427-
if not _cpu_load_percent_primed:
428-
_ = self._get_power_from_cpu_load()
429-
_cpu_load_percent_primed = True
430-
else:
431-
self._cpu_percent_interval = None
478+
# Start a fresh window: the first sample covers the time since now
479+
with self._power_history_lock:
480+
self._power_history = []
481+
self._last_sample_time = time.perf_counter()
482+
self._last_measurement_time = None
483+
self._last_cpu_times = {}
484+
if self._mode == MODE_CPU_LOAD:
485+
if self._tracking_mode == "process":
486+
self._last_measurement_time = time.time()
487+
self._last_cpu_times = self._get_process_cpu_times()
488+
else:
489+
self._cpu_load_meter.reset()
432490

433491
def monitor_power(self):
434-
cpu_power = self._get_power_from_cpus()
435-
self._power_history.append(cpu_power)
492+
with self._power_history_lock:
493+
self._power_history.append(self._take_power_sample())
436494

437495
def get_model(self):
438496
return self._model

0 commit comments

Comments
 (0)