44
55import math
66import re
7+ import threading
78import time
89from abc import ABC , abstractmethod
910from dataclasses import dataclass
2930
3031MODE_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