Repository navigation
fix: lock CPU power history swap - #1335
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1335 +/- ##
==========================================
+ Coverage 92.04% 92.07% +0.03%
==========================================
Files 49 49
Lines 5154 5199 +45
==========================================
+ Hits 4744 4787 +43
- Misses 410 412 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
d70b9ae to
ed2b386
Compare
b813d76 to
f72f432
Compare
Verdict: ✅ Approve with nitsThe race is real. The monitor scheduler can append to the old list between the list comprehension and the
56 tests pass on this PR merged with master. Nits:
|
|
Made the changes in 44ef635: merged master, added a test that a post-swap sample lands in the next window. Not done: updating the description's test paragraph (my PR-edit was blocked; the diff's tests are the lock-blocking one plus the new next-window one, no list subclass). |
benoit-cty
left a comment
There was a problem hiding this comment.
Review follow-up
The lock fixes the swap/append race on _power_history. While reviewing, I found related gaps around it and pushed fixes for all of them.
1. Sampling now happens under the lock
_get_power_from_cpus() was called outside the lock in total_power() and monitor_power(), but in some modes it mutates shared state:
tracking_mode="process": it reads and writes_last_measurement_time/_last_cpu_times. If the two threads interleave, one computes its CPU delta against the other's baseline and records a wrong sample.intel_power_gadget: both threads could run PowerLog into the same log file and read it back. A truncated read returns{}, so a 0 W sample goes into the average.
Averaging the drained list still happens outside the lock, so a monitor sample is never lost: it lands in the next window.
2. CPU.start() starts a clean window
- It clears
_power_history. The monitor scheduler starts beforehardware.start()instart_task(), so samples from between tasks, or left over from the previous task, used to end up in the next task's first window. - In process mode it now takes the CPU-time baseline. Before, the first sample after
start()always reported 0 W ("First measurement, no CPU delta available yet").
3. CPU load no longer shares psutil.cpu_percent()'s global baseline
psutil.cpu_percent(interval=None) keeps one baseline per process. The tracker's _monitor_power calls it every second for cpu_utilization_percent, so in machine/cpu_load mode the latest_sample taken by total_power() often covered only a few milliseconds (frequently 0% or 100%). It was still averaged with the same weight as the 1 s samples.
- A new
SystemCPULoadMetercomputes load frompsutil.cpu_times()with its own baseline, using the same busy/total formula as psutil.start()resets it, which also removes the 50 ms blocking priming and the_cpu_load_percent_primedmodule global (and its hook inhardware_cache.clear_cache()). - Each history entry is now
(power, seconds covered), andtotal_power()returns a time-weighted average. If no durations are known (e.g.start()was never called), it falls back to the plain mean.
4. Tracker utilization histories are locked
_cpu_utilization_history, _ram_utilization_history, _ram_used_history and _gpu_utilization_history had the same race: the monitor thread appended while the main thread cleared them or computed sum()/len(). _monitor_power now collects its readings first and appends them under _utilization_history_lock. Clearing and averaging go through _clear_utilization_history() / _mean_utilization(), which also removes the duplicated clear blocks in start() and start_task().
Tests
- Replaced the two original tests.
test_cpu_total_power_keeps_samples_added_while_drainingnever calledtotal_power(), so it still passed with an unlocked swap. The other test swapped the lock for a hand-written context manager and chose the mocked wattage by thread name. - New tests:
test_cpu_sample_added_while_draining_goes_to_next_windowtest_cpu_total_power_and_monitor_power_never_sample_concurrentlytest_cpu_start_discards_samples_from_before_the_windowtest_cpu_total_power_weights_samples_by_covered_timetest_cpu_load_meter_busy_time_excludes_idle_iowait_and_guesttest_cpu_start_sets_process_cpu_time_baselinetest_monitor_power_waits_while_utilization_history_is_read
- I checked each one by reverting its fix and confirming it fails.
uv run task test-package: 674 passed. The only failures are 7 tests intest_config.py/test_electricitymaps_config_backward_compatibility.py, which pick up a local.codecarbon.configand fail the same way without these changes.
Trade-off
monitor_power() now waits while total_power() takes its sample. That is normally milliseconds, but in Intel Power Gadget mode a PowerLog run can make the monitor tick late. That seems acceptable, since the 1 s cadence is best effort and samples are now time-weighted anyway.
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. The test asserts the drain invariant rather than the drain shape, so it does not pin the implementation. Closes #1315 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…window Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
total_power() and monitor_power() now take the sample inside the lock, so the two threads can no longer sample at the same time. This fixes the process-mode race and the PowerLog log-file race (findings 1 and 2). Averaging still happens outside the lock, so a monitor sample is never lost; it goes into the next window. History cleared on start: start() now clears _power_history under the lock, so samples from before a task don't leak into it (finding 4).
the shared psutil.cpu_percent baseline, which can make one sample cover only a few milliseconds.
c75c93b to
235b9ea
Compare
Description
Fixes a lost-update race on
CPU._power_history. The 1 Hz monitor scheduler thread (CPU.monitor_power) appends to it, and themeasure_power_secsscheduler thread (CPU.total_power) drains it. The read-then-rebind intotal_powerwas not atomic, so a sample appended between the list comprehension andself._power_history = []was dropped.CPUnow has athreading.Lock(_power_history_lock), held only for the O(1) list swap._get_power_from_cpus()still runs outside the lock in both paths, so a slow backend (for example theIntelPowerGadgetsubprocess) never blocks the monitor thread. The unreachableif not power_history_in_W:branch is removed, since a sample is always appended before the average is taken.Related Issue
Fixes #1315
Motivation and Context
In
cpu_loadmode the reportedcpu_poweris the mean of the buffered samples. The samples lost in the unlocked swap window are the ones taken while a slow measurement is in flight, so the error is small but systematic and silent.How Has This Been Tested?
Two tests in
tests/test_cpu_load.py::TestCPULoad:test_cpu_total_power_keeps_samples_added_while_draining: holdscpu._power_history_lockwhilemonitor_power()runs on another thread, checks the monitor waits instead of appending, and that the sample is in the history once the lock is released.test_cpu_sample_appended_right_after_the_swap_goes_to_next_window: wraps the real lock so a 100 Wmonitor_power()sample fires right after the swap, and checks the firsttotal_power()averages the old window (5.5 W) and the second picks up the 100 W sample (50.5 W).CI is green on this branch.
Screenshots (if appropriate):
N/A
Types of changes
AI Usage Disclosure
Checklist: