From 47b1259b491577e8978b27b5496dcf8853464311 Mon Sep 17 00:00:00 2001 From: benoit-cty Date: Sun, 5 Jul 2026 19:18:40 +0200 Subject: [PATCH 01/11] Add Windows support --- codecarbon/core/hardware_cache.py | 1 + codecarbon/core/resource_tracker.py | 35 ++- codecarbon/core/windows_emi.py | 436 ++++++++++++++++++++++++++++ codecarbon/external/hardware.py | 16 +- 4 files changed, 476 insertions(+), 12 deletions(-) create mode 100644 codecarbon/core/windows_emi.py diff --git a/codecarbon/core/hardware_cache.py b/codecarbon/core/hardware_cache.py index 6970a1b64..829de0813 100644 --- a/codecarbon/core/hardware_cache.py +++ b/codecarbon/core/hardware_cache.py @@ -238,6 +238,7 @@ def clear_cache() -> None: ("codecarbon.core.gpu_amd", "clear_rocm_system_cache"), ("codecarbon.core.cpu", "clear_powergadget_cache"), ("codecarbon.core.powermetrics", "clear_powermetrics_cache"), + ("codecarbon.core.windows_emi", "clear_emi_cache"), ): mod = sys.modules.get(mod_name) if mod is not None: diff --git a/codecarbon/core/resource_tracker.py b/codecarbon/core/resource_tracker.py index 8a6496924..e20838718 100644 --- a/codecarbon/core/resource_tracker.py +++ b/codecarbon/core/resource_tracker.py @@ -2,7 +2,7 @@ from concurrent.futures import ThreadPoolExecutor from typing import List, Union -from codecarbon.core import cpu, gpu, powermetrics +from codecarbon.core import cpu, gpu, powermetrics, windows_emi from codecarbon.core.config import normalize_gpu_ids from codecarbon.core.hardware_cache import get_cached_tdp, get_or_run_setup from codecarbon.core.util import ( @@ -73,6 +73,20 @@ def _setup_power_gadget(self): self.tracker._conf["cpu_model"] = hardware_cpu.get_model() return True + def _setup_windows_emi(self): + """Set up CPU tracking using the Windows Energy Meter Interface (RAPL).""" + logger.info("Tracking CPU via the Windows Energy Meter Interface (RAPL)") + self.cpu_tracker = "Windows EMI" + hardware_cpu = CPU.from_utils( + output_dir=self.tracker._output_dir, + mode="windows_emi", + tracking_mode=self.tracker._tracking_mode, + rapl_include_dram=self.tracker._rapl_include_dram, + ) + self.tracker._hardware.append(hardware_cpu) + self.tracker._conf["cpu_model"] = hardware_cpu.get_model() + return True + def _setup_rapl(self): """Set up CPU tracking using RAPL interface.""" logger.info("Tracking Intel CPU via RAPL interface") @@ -118,7 +132,8 @@ def _get_install_instructions(self): return "Mac OS detected: Please install Intel Power Gadget or enable PowerMetrics sudo to measure CPU" elif is_windows_os(): return ( - "Windows OS detected: Please install Intel Power Gadget to measure CPU" + "Windows OS detected: CPU power reading via the Energy Meter " + "Interface requires Windows 11 on bare metal (not a VM)" ) elif is_linux_os(): return "Linux OS detected: Please ensure RAPL files exist, and are readable, at /sys/class/powercap/intel-rapl/subsystem to measure CPU" @@ -222,9 +237,13 @@ def _try_platform_cpu_backend(self) -> bool: elif powermetrics.is_powermetrics_available(): self._setup_powermetrics() return True - elif is_windows_os() and cpu.is_powergadget_available(): - self._setup_power_gadget() - return True + elif is_windows_os(): + if windows_emi.is_emi_available(): + self._setup_windows_emi() + return True + if cpu.is_powergadget_available(): + self._setup_power_gadget() + return True return False def set_CPU_tracking(self): @@ -298,13 +317,11 @@ def _run_full_hardware_setup(self) -> None: cpu_future.result() gpu_future.result() - logger.info( - f"""The below tracking methods have been set up: + logger.info(f"""The below tracking methods have been set up: RAM Tracking Method: {self.ram_tracker} CPU Tracking Method: {self.cpu_tracker} GPU Tracking Method: {self.gpu_tracker} - """ - ) + """) def set_CPU_GPU_ram_tracking(self): """ diff --git a/codecarbon/core/windows_emi.py b/codecarbon/core/windows_emi.py new file mode 100644 index 000000000..0a3a58a13 --- /dev/null +++ b/codecarbon/core/windows_emi.py @@ -0,0 +1,436 @@ +""" +Implements tracking CPU power consumption on Windows using the built-in +Energy Meter Interface (EMI). + +Since Windows 11, the OS kernel exposes the CPU RAPL energy counters +(the same MSR-based counters read from /sys/class/powercap on Linux) +through a standard device interface, for both Intel and AMD CPUs. +No third-party driver, no admin rights and no extra dependency needed. +On Windows 10, EMI only reports on devices with dedicated metering +hardware (e.g. Surface Book). + +https://learn.microsoft.com/en-us/windows-hardware/drivers/powermeter/energy-meter-interface +""" + +from __future__ import annotations + +import struct +import sys +from functools import lru_cache +from typing import Dict, List, Tuple + +from codecarbon.core.units import Time +from codecarbon.external.logger import logger + +# From emi.h: CTL_CODE(FILE_DEVICE_UNKNOWN, fn, METHOD_BUFFERED, FILE_READ_ACCESS) +# = (0x22 << 16) | (1 << 14) | (fn << 2) | 0 +IOCTL_EMI_GET_VERSION = 0x224000 +IOCTL_EMI_GET_METADATA_SIZE = 0x224004 +IOCTL_EMI_GET_METADATA = 0x224008 +IOCTL_EMI_GET_MEASUREMENT = 0x22400C + +EMI_VERSION_V1 = 1 +EMI_VERSION_V2 = 2 + +# Size of EMI_CHANNEL_MEASUREMENT_DATA {ULONGLONG AbsoluteEnergy; ULONGLONG AbsoluteTime;} +EMI_MEASUREMENT_SIZE = 16 + +GUID_DEVICE_ENERGY_METER = "{45BD8344-7ED6-49CF-A440-C276C933B053}" + +# AbsoluteEnergy is in picowatt-hours, AbsoluteTime in 100ns units +PWH_TO_KWH = 1e-15 +PWH_TO_WH = 1e-12 +HNS_TO_S = 1e-7 + +_GENERIC_READ = 0x80000000 +_FILE_SHARE_READ = 0x1 +_FILE_SHARE_WRITE = 0x2 +_OPEN_EXISTING = 3 +_CR_SUCCESS = 0 +_CM_GET_DEVICE_INTERFACE_LIST_PRESENT = 0 + +_IS_WINDOWS = sys.platform.startswith("win") + +if _IS_WINDOWS: + import ctypes + from ctypes import wintypes + + _cfgmgr32 = ctypes.WinDLL("cfgmgr32", use_last_error=True) + _kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + _ole32 = ctypes.WinDLL("ole32", use_last_error=True) + + class _GUID(ctypes.Structure): + _fields_ = [ + ("Data1", ctypes.c_ulong), + ("Data2", ctypes.c_ushort), + ("Data3", ctypes.c_ushort), + ("Data4", ctypes.c_ubyte * 8), + ] + + _kernel32.CreateFileW.restype = wintypes.HANDLE + _kernel32.CreateFileW.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ] + _kernel32.DeviceIoControl.restype = wintypes.BOOL + _kernel32.DeviceIoControl.argtypes = [ + wintypes.HANDLE, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + wintypes.LPVOID, + ] + _kernel32.CloseHandle.restype = wintypes.BOOL + _kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + + _INVALID_HANDLE_VALUE = wintypes.HANDLE(-1).value + + +def list_emi_device_paths() -> List[str]: + """ + Enumerate the device interface paths of all present energy meters. + """ + if not _IS_WINDOWS: + return [] + guid = _GUID() + if _ole32.CLSIDFromString(GUID_DEVICE_ENERGY_METER, ctypes.byref(guid)) != 0: + return [] + size = wintypes.ULONG(0) + cr = _cfgmgr32.CM_Get_Device_Interface_List_SizeW( + ctypes.byref(size), + ctypes.byref(guid), + None, + _CM_GET_DEVICE_INTERFACE_LIST_PRESENT, + ) + if cr != _CR_SUCCESS or size.value <= 1: + return [] + buffer = ctypes.create_unicode_buffer(size.value) + cr = _cfgmgr32.CM_Get_Device_Interface_ListW( + ctypes.byref(guid), + None, + buffer, + size, + _CM_GET_DEVICE_INTERFACE_LIST_PRESENT, + ) + if cr != _CR_SUCCESS: + return [] + # The buffer holds a REG_MULTI_SZ-style list of nul-terminated strings + paths = [] + current: List[str] = [] + for char in buffer[: size.value]: + if char == "\x00": + if current: + paths.append("".join(current)) + current = [] + else: + current.append(char) + return paths + + +class _EmiDeviceHandle: + """Context manager around a CreateFile handle on an EMI device.""" + + def __init__(self, device_path: str): + self._device_path = device_path + self._handle = None + + def __enter__(self): + handle = _kernel32.CreateFileW( + self._device_path, + _GENERIC_READ, + _FILE_SHARE_READ | _FILE_SHARE_WRITE, + None, + _OPEN_EXISTING, + 0, + None, + ) + if handle is None or handle == _INVALID_HANDLE_VALUE: + error = ctypes.get_last_error() + raise OSError(error, ctypes.FormatError(error), self._device_path) + self._handle = handle + return self + + def __exit__(self, *exc_info): + if self._handle is not None: + _kernel32.CloseHandle(self._handle) + self._handle = None + + def ioctl(self, code: int, output_size: int) -> bytes: + output = ctypes.create_string_buffer(output_size) + returned = wintypes.DWORD(0) + ok = _kernel32.DeviceIoControl( + self._handle, + code, + None, + 0, + output, + output_size, + ctypes.byref(returned), + None, + ) + if not ok: + error = ctypes.get_last_error() + raise OSError(error, ctypes.FormatError(error), self._device_path) + return output.raw[: returned.value] + + +def _decode_wchars(raw: bytes) -> str: + return raw.decode("utf-16-le", errors="replace").split("\x00")[0] + + +def parse_channel_names(version: int, metadata: bytes) -> List[str]: + """ + Extract the channel names from an EMI_METADATA_V1/V2 buffer. + """ + if version == EMI_VERSION_V2: + # EMI_METADATA_V2: WCHAR[16] OEM, WCHAR[16] Model, + # USHORT Revision, USHORT ChannelCount, EMI_CHANNEL_V2 Channels[] + (channel_count,) = struct.unpack_from(" List[Tuple[int, int]]: + """ + Parse a buffer of EMI_CHANNEL_MEASUREMENT_DATA into a list of + (absolute_energy_pwh, absolute_time_100ns) tuples. + """ + measurements = [] + for offset in range(0, len(raw) - EMI_MEASUREMENT_SIZE + 1, EMI_MEASUREMENT_SIZE): + measurements.append(struct.unpack_from(" List[str]: + """Read version + metadata of an EMI device and return its channel names.""" + with _EmiDeviceHandle(device_path) as device: + (version,) = struct.unpack_from(" List[Tuple[int, int]]: + """Read the current measurement of every channel of an EMI device.""" + with _EmiDeviceHandle(device_path) as device: + raw = device.ioctl( + IOCTL_EMI_GET_MEASUREMENT, EMI_MEASUREMENT_SIZE * channel_count + ) + return parse_measurements(raw) + + +def select_channel_indices( + channel_names: List[str], include_dram: bool = False +) -> List[int]: + """ + Select the channels to monitor among the ones exposed by a device. + + Package channels (e.g. ``RAPL_Package0_PKG``) are preferred: they measure + the whole CPU package, while PP0/PP1 are subdomains of the package and + would double-count. If no package channel is identified, all channels are + used, as with unknown RAPL domains on Linux. + """ + selected = [i for i, name in enumerate(channel_names) if "pkg" in name.lower()] + if not selected: + if len(channel_names) == 1: + selected = [0] + else: + logger.warning( + "\tEMI - No package channel identified among %s, using all channels", + channel_names, + ) + return list(range(len(channel_names))) + if include_dram: + selected += [ + i for i, name in enumerate(channel_names) if "dram" in name.lower() + ] + return selected + + +class WindowsEMI: + """ + A class to interface the Windows Energy Meter Interface (EMI) for + monitoring CPU power consumption. + + It mirrors the semantics of the Linux `IntelRAPL` interface: EMI exposes + the same RAPL cumulative energy counters (in picowatt-hours) which are + read at each measurement and converted to energy deltas. + + Args: + emi_include_dram (bool): Include DRAM channels in the measurement + (default: False, CPU package only). + + Methods: + start(): + Takes the initial energy counter snapshot. + + get_cpu_details(duration: Time) -> Dict: + Fetches the CPU energy deltas since the previous call. + + get_static_cpu_details() -> Dict: + Returns the last computed CPU details without recalculating them. + """ + + def __init__(self, emi_include_dram: bool = False): + self._system = sys.platform.lower() + self.emi_include_dram = emi_include_dram + # List of (device_path, channel_names, selected_channel_indices) + self._devices: List[Tuple[str, List[str], List[int]]] = [] + # (device_path, channel_index) -> (absolute_energy_pwh, absolute_time_100ns) + self._last_measurements: Dict[Tuple[str, int], Tuple[int, int]] = {} + self._cpu_details: Dict = {} + self._setup_emi() + + def _setup_emi(self) -> None: + if not self._system.startswith("win"): + raise SystemError("Platform not supported by the Energy Meter Interface") + device_paths = list_emi_device_paths() + if not device_paths: + raise FileNotFoundError( + "No Energy Meter Interface device found. EMI requires Windows 11 " + "running on bare metal (or a Windows 10 device with metering hardware)." + ) + for device_path in device_paths: + try: + channel_names = _read_device_channels(device_path) + except (OSError, ValueError) as e: + logger.debug( + "\tEMI - Unable to read metadata of %s: %s", device_path, e + ) + continue + selected = select_channel_indices(channel_names, self.emi_include_dram) + if not selected: + continue + for index in selected: + logger.info( + "\tEMI - Monitoring channel '%s' of device %s", + channel_names[index], + device_path, + ) + self._devices.append((device_path, channel_names, selected)) + if not self._devices: + raise FileNotFoundError("No readable Energy Meter Interface channel found.") + + def _snapshot(self) -> Dict[Tuple[str, int], Tuple[int, int]]: + """Read the current counters of all monitored channels.""" + snapshot: Dict[Tuple[str, int], Tuple[int, int]] = {} + for device_path, channel_names, selected in self._devices: + try: + measurements = _read_device_measurements( + device_path, len(channel_names) + ) + except OSError as e: + logger.info("\tEMI - Unable to read %s: %s", device_path, e) + continue + for index in selected: + if index < len(measurements): + snapshot[(device_path, index)] = measurements[index] + return snapshot + + def start(self) -> None: + """ + Starts monitoring CPU energy consumption by taking the initial + counter snapshot. + """ + self._last_measurements = self._snapshot() + + def get_cpu_details(self, duration: Time) -> Dict: + """ + Fetches the CPU Energy Deltas by reading the EMI counters and + subtracting the previous snapshot. + """ + cpu_details: Dict = {} + snapshot = self._snapshot() + channel_index = 0 + for device_path, channel_names, selected in self._devices: + for index in selected: + key = (device_path, index) + current = snapshot.get(key) + previous = self._last_measurements.get(key) + energy_kwh = 0.0 + power_w = 0.0 + if current and previous: + delta_pwh = current[0] - previous[0] + delta_time_s = (current[1] - previous[1]) * HNS_TO_S + if delta_time_s <= 0: + delta_time_s = duration.seconds + if delta_pwh >= 0 and delta_time_s > 0: + energy_kwh = delta_pwh * PWH_TO_KWH + power_w = delta_pwh * PWH_TO_WH * 3600 / delta_time_s + else: + logger.debug( + "\tEMI - Skipping negative delta on channel '%s'", + channel_names[index], + ) + name = f"Processor Energy Delta_{channel_index}(kWh)" + cpu_details[name] = energy_kwh + # We fake the names used by Power Gadget, as IntelRAPL does + cpu_details[name.replace("Energy", "Power")] = power_w + channel_index += 1 + self._last_measurements.update(snapshot) + self._cpu_details = cpu_details + logger.debug("get_cpu_details %s", self._cpu_details) + return cpu_details + + def get_static_cpu_details(self) -> Dict: + """ + Return CPU details without computing them. + """ + return self._cpu_details + + +@lru_cache(maxsize=1) +def is_emi_available() -> bool: + """ + Checks if the Windows Energy Meter Interface is available on the system. + + Returns: + bool: `True` if EMI is available, `False` otherwise. + """ + if not _IS_WINDOWS: + return False + try: + emi = WindowsEMI() + # Make sure the counters are actually readable + if not emi._snapshot(): + logger.debug("Not using EMI: no readable measurement") + return False + return True + except Exception as e: + logger.debug( + "Not using EMI, an exception occurred while instantiating " + + "WindowsEMI : %s", + e, + ) + return False + + +def clear_emi_cache() -> None: + is_emi_available.cache_clear() diff --git a/codecarbon/external/hardware.py b/codecarbon/external/hardware.py index 5074f69b9..9c6d113cc 100644 --- a/codecarbon/external/hardware.py +++ b/codecarbon/external/hardware.py @@ -16,6 +16,7 @@ from codecarbon.core.powermetrics import ApplePowermetrics from codecarbon.core.units import Energy, Power, Time from codecarbon.core.util import count_cpus, detect_cpu_model +from codecarbon.core.windows_emi import WindowsEMI from codecarbon.external.logger import logger # default W value for a CPU if no model is found in the ref csv @@ -229,6 +230,10 @@ def __init__( rapl_include_dram=rapl_include_dram, rapl_prefer_psys=rapl_prefer_psys, ) + elif self._mode == "windows_emi": + self._intel_interface = WindowsEMI( + emi_include_dram=rapl_include_dram, + ) def __repr__(self) -> str: if self._mode != "constant": @@ -357,7 +362,7 @@ def _get_power_from_cpus(self) -> Power: elif self._mode == "constant": power = self._tdp * CONSUMPTION_PERCENTAGE_CONSTANT return Power.from_watts(power) - if self._mode == "intel_rapl": + if self._mode in ("intel_rapl", "windows_emi"): # Don't call get_cpu_details to avoid computing energy twice and losing data. all_cpu_details: Dict = self._intel_interface.get_static_cpu_details() else: @@ -398,7 +403,7 @@ def total_power(self) -> Power: return Power.from_watts(cpu_power) def measure_power_and_energy(self, last_duration: float) -> Tuple[Power, Energy]: - if self._mode == "intel_rapl": + if self._mode in ("intel_rapl", "windows_emi"): energy = self._get_energy_from_cpus(delay=Time(seconds=last_duration)) power = self.total_power() return power, energy @@ -408,7 +413,12 @@ def measure_power_and_energy(self, last_duration: float) -> Tuple[Power, Energy] def start(self): global _cpu_load_percent_primed - if self._mode in ["intel_power_gadget", "intel_rapl", "apple_powermetrics"]: + if self._mode in [ + "intel_power_gadget", + "intel_rapl", + "windows_emi", + "apple_powermetrics", + ]: self._intel_interface.start() # Reset process tracking state for fresh measurements self._last_measurement_time = None From 75e832de7e02d7018a7c78ac00a286cf6790baf9 Mon Sep 17 00:00:00 2001 From: benoit-cty Date: Sun, 5 Jul 2026 19:18:53 +0200 Subject: [PATCH 02/11] Add tests --- tests/test_cpu_load.py | 2 + tests/test_resource_tracker.py | 55 +++++++- tests/test_tracking_inference.py | 5 +- tests/test_windows_emi.py | 232 +++++++++++++++++++++++++++++++ 4 files changed, 291 insertions(+), 3 deletions(-) create mode 100644 tests/test_windows_emi.py diff --git a/tests/test_cpu_load.py b/tests/test_cpu_load.py index ecb9b2d27..7f6243043 100644 --- a/tests/test_cpu_load.py +++ b/tests/test_cpu_load.py @@ -49,12 +49,14 @@ def test_cpu_total_power( self.assertEqual(power.W, 50) self.assertEqual(cpu.total_power().W, 50) + @mock.patch("codecarbon.core.windows_emi.is_emi_available", return_value=False) @mock.patch( "codecarbon.core.powermetrics.is_powermetrics_available", return_value=False ) def test_cpu_load_detection( self, mocked_is_powermetrics_available, + mocked_is_emi_available, mocked_is_psutil_available, mocked_is_powergadget_available, mocked_is_rapl_available, diff --git a/tests/test_resource_tracker.py b/tests/test_resource_tracker.py index c20fcf1d5..27a7d1b4d 100644 --- a/tests/test_resource_tracker.py +++ b/tests/test_resource_tracker.py @@ -29,7 +29,7 @@ def make_tracker(**overrides): (True, False, False, "Apple M4", "PowerMetrics sudo"), (True, False, False, "Intel Core i7", "Intel Power Gadget"), (True, False, False, None, "Intel Power Gadget"), - (False, True, False, "Intel Core i7", "Intel Power Gadget"), + (False, True, False, "Intel Core i7", "Energy Meter Interface"), (False, False, True, "Intel Core i7", "RAPL"), (False, False, False, "Intel Core i7", ""), ], @@ -214,7 +214,7 @@ def test_set_cpu_tracking_force_mode_uses_cpu_load_and_returns(): mock_setup.assert_called_once_with(fake_tdp, 80) -def test_set_cpu_tracking_prefers_power_gadget(): +def test_set_cpu_tracking_windows_prefers_emi(): tracker = make_tracker() resource_tracker = ResourceTracker(tracker) @@ -222,6 +222,35 @@ def test_set_cpu_tracking_prefers_power_gadget(): patch("codecarbon.core.resource_tracker.is_mac_os", return_value=False), patch("codecarbon.core.resource_tracker.is_linux_os", return_value=False), patch("codecarbon.core.resource_tracker.is_windows_os", return_value=True), + patch( + "codecarbon.core.resource_tracker.windows_emi.is_emi_available", + return_value=True, + ), + patch( + "codecarbon.core.resource_tracker.cpu.is_powergadget_available", + return_value=True, + ), + patch.object(resource_tracker, "_setup_windows_emi") as mock_emi, + patch.object(resource_tracker, "_setup_power_gadget") as mock_power_gadget, + ): + resource_tracker.set_CPU_tracking() + + mock_emi.assert_called_once_with() + mock_power_gadget.assert_not_called() + + +def test_set_cpu_tracking_windows_falls_back_to_power_gadget(): + tracker = make_tracker() + resource_tracker = ResourceTracker(tracker) + + with ( + patch("codecarbon.core.resource_tracker.is_mac_os", return_value=False), + patch("codecarbon.core.resource_tracker.is_linux_os", return_value=False), + patch("codecarbon.core.resource_tracker.is_windows_os", return_value=True), + patch( + "codecarbon.core.resource_tracker.windows_emi.is_emi_available", + return_value=False, + ), patch( "codecarbon.core.resource_tracker.cpu.is_powergadget_available", return_value=True, @@ -529,6 +558,28 @@ def test_setup_power_gadget_configures_tracker(): assert tracker._hardware == [hardware_cpu] +def test_setup_windows_emi_configures_tracker(): + tracker = make_tracker() + resource_tracker = ResourceTracker(tracker) + hardware_cpu = MagicMock() + hardware_cpu.get_model.return_value = "Intel CPU" + + with patch( + "codecarbon.core.resource_tracker.CPU.from_utils", return_value=hardware_cpu + ) as mock_from_utils: + assert resource_tracker._setup_windows_emi() is True + + mock_from_utils.assert_called_once_with( + output_dir="out", + mode="windows_emi", + tracking_mode="machine", + rapl_include_dram=False, + ) + assert resource_tracker.cpu_tracker == "Windows EMI" + assert tracker._conf["cpu_model"] == "Intel CPU" + assert tracker._hardware == [hardware_cpu] + + def test_setup_fallback_tracking_uses_forced_cpu_power(): tracker = make_tracker(_force_cpu_power=99) resource_tracker = ResourceTracker(tracker) diff --git a/tests/test_tracking_inference.py b/tests/test_tracking_inference.py index 2ea3471d5..66a8dad54 100644 --- a/tests/test_tracking_inference.py +++ b/tests/test_tracking_inference.py @@ -57,7 +57,10 @@ def test_tracker_measures_model_loading_task(self): tracker.stop() # Then - assert actual_cpu_energy < tracker.final_emissions_data.cpu_energy + # <= because counter-based backends (RAPL, Windows EMI) may report a + # zero energy delta between stop_task() and stop() when both happen + # within the hardware counter refresh granularity. + assert actual_cpu_energy <= tracker.final_emissions_data.cpu_energy assert actual_gpu_energy <= tracker.final_emissions_data.gpu_energy assert actual_ram_energy < tracker.final_emissions_data.ram_energy diff --git a/tests/test_windows_emi.py b/tests/test_windows_emi.py new file mode 100644 index 000000000..2533b8a63 --- /dev/null +++ b/tests/test_windows_emi.py @@ -0,0 +1,232 @@ +import struct +import sys +import unittest +from unittest import mock + +from codecarbon.core.units import Time +from codecarbon.core.windows_emi import ( + EMI_VERSION_V1, + EMI_VERSION_V2, + WindowsEMI, + clear_emi_cache, + is_emi_available, + parse_channel_names, + parse_measurements, + select_channel_indices, +) + +CHANNELS = [ + "RAPL_Package0_PKG", + "RAPL_Package0_DRAM", + "RAPL_Package0_PP0", + "RAPL_Package0_PP1", +] + + +def build_metadata_v2(channel_names): + metadata = "Microsoft".encode("utf-16-le").ljust(32, b"\x00") + metadata += "PPM".encode("utf-16-le").ljust(32, b"\x00") + metadata += struct.pack(" Date: Sun, 5 Jul 2026 19:19:07 +0200 Subject: [PATCH 03/11] Documentation --- docs/explanation/methodology.md | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/docs/explanation/methodology.md b/docs/explanation/methodology.md index 968d55dc1..e247816e8 100644 --- a/docs/explanation/methodology.md +++ b/docs/explanation/methodology.md @@ -108,7 +108,7 @@ The `tracking_mode` parameter (values: `"machine"` or `"process"`, default `"mac > ⚠️ **GPU limitation**: Process Mode only affects CPU and RAM attribution. GPU power is always measured at the device level, so if you share a GPU with other users or processes, CodeCarbon will still account for the **entire GPU's** power consumption, not just your share. -Note: The underlying measurement method (Intel RAPL, Intel Power Gadget, TDP-based CPU-load estimation…) is chosen automatically based on hardware availability and software permissions. It applies independently of the tracking mode. +Note: The underlying measurement method (Intel RAPL, Windows Energy Meter Interface, Intel Power Gadget, TDP-based CPU-load estimation…) is chosen automatically based on hardware availability and software permissions. It applies independently of the tracking mode. ### GPU @@ -201,7 +201,26 @@ CodeCarbon. ### CPU -- **On Windows or Mac (Intel)** +- **On Windows** + +Tracks Intel and AMD processor energy consumption using the [Energy +Meter Interface +(EMI)](https://learn.microsoft.com/en-us/windows-hardware/drivers/powermeter/energy-meter-interface), +through which Windows 11 exposes the CPU RAPL energy counters (the same +hardware counters CodeCarbon reads on Linux). It is built into the OS: +no third-party driver, no administrator rights and no extra dependency +are needed. + +*Note*: EMI reports CPU power only on Windows 11 running on bare metal +(on Windows 10, only on devices with dedicated metering hardware, such +as the Surface Book). On virtual machines or older Windows versions, +CodeCarbon falls back to the CPU-load estimation mode described below. + +Legacy support for `Intel Power Gadget` is kept for machines where it is +still installed, but the tool [has been discontinued by +Intel](https://github.com/mlco2/codecarbon/issues/457). + +- **On Mac (Intel)** Tracks Intel processors energy consumption using the `Intel Power Gadget`. You need to install it yourself from this @@ -284,7 +303,8 @@ Read more about how we use it in [RAPL Metrics](rapl.md). ## CPU metrics priority CodeCarbon will first try to read the energy consumption of the CPU from -low level interface like RAPL or `powermetrics`. If none of the tracking +a low level interface like RAPL (on Linux), the Energy Meter Interface +(on Windows 11) or `powermetrics` (on macOS). If none of the tracking tools are available, CodeCarbon will be switched to a fallback mode: - It will first detect which CPU hardware is currently in use, and From 3edaec9475bc79884529ce3a282a21b5e77c7818 Mon Sep 17 00:00:00 2001 From: benoit-cty Date: Wed, 22 Jul 2026 10:53:43 +0200 Subject: [PATCH 04/11] Improve test coverage --- tests/test_windows_emi.py | 253 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) diff --git a/tests/test_windows_emi.py b/tests/test_windows_emi.py index 2533b8a63..01647662c 100644 --- a/tests/test_windows_emi.py +++ b/tests/test_windows_emi.py @@ -1,8 +1,10 @@ import struct import sys +import types import unittest from unittest import mock +from codecarbon.core import windows_emi from codecarbon.core.units import Time from codecarbon.core.windows_emi import ( EMI_VERSION_V1, @@ -14,6 +16,7 @@ parse_measurements, select_channel_indices, ) +from codecarbon.external.hardware import CPU CHANNELS = [ "RAPL_Package0_PKG", @@ -140,6 +143,21 @@ def test_get_cpu_details_skips_negative_delta(self, mock_channels, mock_paths): self.assertEqual(details["Processor Energy Delta_0(kWh)"], 0.0) self.assertEqual(details["Processor Power Delta_0(kWh)"], 0.0) + def test_snapshot_skips_unreadable_device(self, mock_channels, mock_paths): + emi = self._make_emi() + with mock.patch( + "codecarbon.core.windows_emi._read_device_measurements", + side_effect=OSError( + 31, "A device attached to the system is not functioning" + ), + ): + emi.start() + details = emi.get_cpu_details(Time(seconds=1)) + + self.assertEqual(emi._last_measurements, {}) + self.assertEqual(details["Processor Energy Delta_0(kWh)"], 0.0) + self.assertEqual(details["Processor Power Delta_0(kWh)"], 0.0) + def test_include_dram_channel(self, mock_channels, mock_paths): emi = self._make_emi(emi_include_dram=True) self.assertEqual(emi._devices, [("dev0", CHANNELS, [0, 1])]) @@ -156,6 +174,204 @@ def test_include_dram_channel(self, mock_channels, mock_paths): self.assertIn("Processor Energy Delta_1(kWh)", details) +class _ValueRef: + """Stand-in for a ctypes integer passed with byref().""" + + def __init__(self, value=0): + self.value = value + + +def _make_fake_ctypes(): + fake = types.SimpleNamespace() + fake.byref = lambda obj: obj + fake.get_last_error = lambda: 5 + fake.FormatError = lambda error: "Access is denied." + return fake + + +class TestListEmiDevicePaths(unittest.TestCase): + """ + Exercise the device enumeration against fake cfgmgr32/ole32 libraries, + so that the ctypes plumbing is tested on every platform. + """ + + MULTI_SZ = "\\\\?\\EMI0\x00\\\\?\\EMI1\x00\x00" + + def _patch(self, ole32, cfgmgr32, buffer=""): + fake_ctypes = _make_fake_ctypes() + fake_ctypes.create_unicode_buffer = lambda size: buffer + return mock.patch.multiple( + windows_emi, + create=True, + _IS_WINDOWS=True, + ctypes=fake_ctypes, + wintypes=types.SimpleNamespace(ULONG=_ValueRef), + _GUID=mock.Mock, + _ole32=ole32, + _cfgmgr32=cfgmgr32, + ) + + def _make_cfgmgr32(self, size, size_cr=0, list_cr=0): + cfgmgr32 = mock.Mock() + + def get_size(size_ref, guid_ref, _filter, _flags): + size_ref.value = size + return size_cr + + cfgmgr32.CM_Get_Device_Interface_List_SizeW.side_effect = get_size + cfgmgr32.CM_Get_Device_Interface_ListW.return_value = list_cr + return cfgmgr32 + + def test_returns_empty_on_non_windows(self): + with mock.patch.object(windows_emi, "_IS_WINDOWS", False): + self.assertEqual(windows_emi.list_emi_device_paths(), []) + + def test_lists_present_devices(self): + ole32 = mock.Mock() + ole32.CLSIDFromString.return_value = 0 + cfgmgr32 = self._make_cfgmgr32(size=len(self.MULTI_SZ)) + with self._patch(ole32, cfgmgr32, buffer=self.MULTI_SZ): + self.assertEqual( + windows_emi.list_emi_device_paths(), + ["\\\\?\\EMI0", "\\\\?\\EMI1"], + ) + + def test_returns_empty_when_guid_parsing_fails(self): + ole32 = mock.Mock() + ole32.CLSIDFromString.return_value = 0x800401F3 # CO_E_CLASSSTRING + with self._patch(ole32, mock.Mock()): + self.assertEqual(windows_emi.list_emi_device_paths(), []) + + def test_returns_empty_when_size_query_fails(self): + ole32 = mock.Mock() + ole32.CLSIDFromString.return_value = 0 + cfgmgr32 = self._make_cfgmgr32(size=0, size_cr=13) # CR_FAILURE + with self._patch(ole32, cfgmgr32): + self.assertEqual(windows_emi.list_emi_device_paths(), []) + + def test_returns_empty_when_no_device_present(self): + ole32 = mock.Mock() + ole32.CLSIDFromString.return_value = 0 + # An empty interface list is reported as a single nul character + cfgmgr32 = self._make_cfgmgr32(size=1) + with self._patch(ole32, cfgmgr32): + self.assertEqual(windows_emi.list_emi_device_paths(), []) + + def test_returns_empty_when_list_query_fails(self): + ole32 = mock.Mock() + ole32.CLSIDFromString.return_value = 0 + cfgmgr32 = self._make_cfgmgr32(size=len(self.MULTI_SZ), list_cr=13) + with self._patch(ole32, cfgmgr32, buffer=self.MULTI_SZ): + self.assertEqual(windows_emi.list_emi_device_paths(), []) + + +class TestEmiDeviceHandle(unittest.TestCase): + """Exercise the CreateFile/DeviceIoControl wrapper against a fake kernel32.""" + + def _patch(self, kernel32, string_buffer=None): + fake_ctypes = _make_fake_ctypes() + fake_ctypes.create_string_buffer = lambda size: string_buffer + return mock.patch.multiple( + windows_emi, + create=True, + ctypes=fake_ctypes, + wintypes=types.SimpleNamespace(DWORD=_ValueRef), + _kernel32=kernel32, + _INVALID_HANDLE_VALUE=-1, + ) + + def test_opens_and_closes_the_device(self): + kernel32 = mock.Mock() + kernel32.CreateFileW.return_value = 42 + with self._patch(kernel32): + with windows_emi._EmiDeviceHandle("dev0") as device: + self.assertEqual(device._handle, 42) + kernel32.CloseHandle.assert_called_once_with(42) + + def test_open_failure_raises_oserror(self): + kernel32 = mock.Mock() + kernel32.CreateFileW.return_value = -1 + with self._patch(kernel32): + with self.assertRaises(OSError): + windows_emi._EmiDeviceHandle("dev0").__enter__() + kernel32.CloseHandle.assert_not_called() + + def test_ioctl_returns_output_buffer(self): + payload = struct.pack(" Date: Wed, 22 Jul 2026 11:21:44 +0200 Subject: [PATCH 05/11] Update precommit --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0037266fd..b2d81156a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/PyCQA/autoflake - rev: v2.3.1 + rev: v2.3.3 hooks: - id: autoflake args: @@ -11,12 +11,12 @@ repos: --expand-star-imports, ] - repo: https://github.com/pycqa/isort - rev: 7.0.0 + rev: 9.0.0b1 hooks: - id: isort args: ["--filter-files"] - repo: https://github.com/psf/black - rev: 25.11.0 + rev: 26.5.1 hooks: - id: black args: [--safe] From f0152ee44de3dc75e292d9a78d35bd802bde6a39 Mon Sep 17 00:00:00 2001 From: benoit-cty Date: Wed, 22 Jul 2026 11:23:10 +0200 Subject: [PATCH 06/11] Fix Precommit --- .gitignore | 1 - codecarbon/core/powermetrics.py | 6 +-- deploy/deploy.py | 30 +++++-------- examples/ollama_local_api.py | 7 +--- examples/rapl/check_powerstat_approach.py | 12 ++---- examples/rapl/test_dram_option.py | 6 +-- tests/test_config.py | 42 +++++++------------ ...icitymaps_config_backward_compatibility.py | 12 ++---- tests/test_ram.py | 18 +++----- 9 files changed, 44 insertions(+), 90 deletions(-) diff --git a/.gitignore b/.gitignore index 7f6619314..f80acb511 100644 --- a/.gitignore +++ b/.gitignore @@ -104,7 +104,6 @@ venv.bak/ # mkdocs documentation /site -/deploy .deploy-docs-workdir/ # mypy diff --git a/codecarbon/core/powermetrics.py b/codecarbon/core/powermetrics.py index b59995154..bffc19ce4 100644 --- a/codecarbon/core/powermetrics.py +++ b/codecarbon/core/powermetrics.py @@ -67,13 +67,11 @@ def _has_powermetrics_sudo() -> bool: _, stderr = process.communicate() if re.search(r"[sudo].*password", stderr): - logger.debug( - """Not using PowerMetrics, sudo password prompt detected. + logger.debug("""Not using PowerMetrics, sudo password prompt detected. If you want to enable Powermetrics please modify your sudoers file as described in : https://docs.codecarbon.io/latest/explanation/methodology/#power-usage - """ - ) + """) return False if process.returncode != 0: raise Exception("Return code != 0") diff --git a/deploy/deploy.py b/deploy/deploy.py index 7442ac717..69a9a7cb4 100755 --- a/deploy/deploy.py +++ b/deploy/deploy.py @@ -114,8 +114,7 @@ def _setup(settings: Settings): }, ) - print( - f""" + print(f""" You might need to add the following entries to your /etc/hosts: local_ip {settings.hostname} {settings.fief_hostname} webapp.local @@ -126,8 +125,7 @@ def _setup(settings: Settings): Useful informations: Fief admin username: admin@mydomain.com Fief admin password: {settings.fief_admin_password} - """ - ) + """) print("To start:") print( @@ -252,13 +250,11 @@ def configure_fief(): }, ).json() cli_client_id = cli_client["id"] - print( - f"""Run the following setup to use the cli: + print(f"""Run the following setup to use the cli: export AUTH_SERVER_URL=http://{fief_settings.fief_domain} export API_URL=http://{carbonserver_settings.app_hostname}/api export AUTH_CLIENT_ID={cli_client_id} - """ - ) + """) @app.command() @@ -325,30 +321,24 @@ def start( cwd="./", ) - print( - """ + print(""" ========================================= Your codecarbon server is now running ! You can access it: http://codecarbon.local -""" - ) +""") if fief: - print( - """ + print(""" The fief server is running: http://fief.localhost -""" - ) - print( - """ +""") + print(""" You can run the webapp locally for local development on the port 3000 and access it: http://webapp.local The registration code for new users can be found by running: docker logs fief_fief-worker_1 - """ - ) + """) @app.command() diff --git a/examples/ollama_local_api.py b/examples/ollama_local_api.py index 41863306c..4ef99416e 100644 --- a/examples/ollama_local_api.py +++ b/examples/ollama_local_api.py @@ -41,12 +41,9 @@ def extract_text_from_url(url): extracted_text = extract_text_from_url(url) # print(extracted_text) -prompt = ( - """ +prompt = """ Merci de me faire un compte rendu des différents points discutés lors de cette réunion. -""" - + extracted_text -) +""" + extracted_text def call_ollama_api(endpoint, payload): diff --git a/examples/rapl/check_powerstat_approach.py b/examples/rapl/check_powerstat_approach.py index db32fcc47..66d359bea 100644 --- a/examples/rapl/check_powerstat_approach.py +++ b/examples/rapl/check_powerstat_approach.py @@ -60,8 +60,7 @@ print("\n" + "=" * 80) print("Powerstat approach (from powerstat.c analysis):") print("=" * 80) -print( - """ +print(""" Powerstat reads ALL top-level domains and DEDUPLICATES by domain name: 1. Scans /sys/class/powercap/intel-rapl:* 2. Reads each domain's 'name' file @@ -74,14 +73,12 @@ - psys (if unique, or skipped if duplicate) Total = package-0 + dram + (other unique domains) -""" -) +""") print("\n" + "=" * 80) print("Recommendation for CodeCarbon:") print("=" * 80) -print( - """ +print(""" Option 1 (Current - CPU only): ✓ Read only package-0 domain ✓ Most accurate for CPU power measurement @@ -98,5 +95,4 @@ ✓ Let users choose via config parameter ✓ Default to package-0 (CPU only) for accuracy ✓ Allow 'all' mode to sum package+dram like powerstat -""" -) +""") diff --git a/examples/rapl/test_dram_option.py b/examples/rapl/test_dram_option.py index 417f696dc..1360b5729 100644 --- a/examples/rapl/test_dram_option.py +++ b/examples/rapl/test_dram_option.py @@ -82,8 +82,7 @@ print("\n" + "=" * 80) print("💡 Analysis") print("=" * 80) -print( - f""" +print(f""" ✓ CPU-only (default): Most accurate for CPU power tracking - Matches CPU TDP specs (15W for i7-7600U) - Best for comparing CPU performance/efficiency @@ -102,5 +101,4 @@ - Other platform components RAPL can only measure CPU + DRAM on your system. -""" -) +""") diff --git a/tests/test_config.py b/tests/test_config.py index 5efb0821d..9a11515e6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -106,21 +106,17 @@ def test_parse_env_config(self): ) def test_read_confs(self): - global_conf = dedent( - """\ + global_conf = dedent("""\ [codecarbon] no_overwrite=path/to/somewhere local_overwrite=ERROR:not overwritten syntax_test_key= no/space= problem2 - """ - ) - local_conf = dedent( - """\ + """) + local_conf = dedent("""\ [codecarbon] local_overwrite=SUCCESS:overwritten local_new_key=cool value - """ - ) + """) with patch( "builtins.open", new_callable=get_custom_mock_open(global_conf, local_conf) @@ -145,23 +141,19 @@ def test_read_confs(self): }, ) def test_read_confs_and_parse_envs(self): - global_conf = dedent( - """\ + global_conf = dedent("""\ [codecarbon] no_overwrite=path/to/somewhere local_overwrite=ERROR:not overwritten syntax_test_key= no/space= problem2 env_overwrite=ERROR:not overwritten - """ - ) - local_conf = dedent( - """\ + """) + local_conf = dedent("""\ [codecarbon] local_overwrite=SUCCESS:overwritten local_new_key=cool value env_overwrite=ERROR:not overwritten - """ - ) + """) with patch( "builtins.open", new_callable=get_custom_mock_open(global_conf, local_conf) @@ -199,8 +191,7 @@ def test_empty_conf(self): }, ) def test_full_hierarchy(self): - global_conf = dedent( - """\ + global_conf = dedent("""\ [codecarbon] measure_power_secs=10 force_cpu_power=toto @@ -208,16 +199,13 @@ def test_full_hierarchy(self): output_dir=ERROR:not overwritten save_to_file=ERROR:not overwritten force_carbon_intensity_g_co2e_kwh=123.4 - """ - ) - local_conf = dedent( - """\ + """) + local_conf = dedent("""\ [codecarbon] output_dir=/success/overwritten emissions_endpoint=http://testhost:2000 gpu_ids=ERROR:not overwritten - """ - ) + """) with patch( "builtins.open", new_callable=get_custom_mock_open(global_conf, local_conf) @@ -239,12 +227,10 @@ def test_full_hierarchy(self): self.assertTrue(tracker._save_to_file) def test_force_carbon_intensity_constructor_overrides_config(self): - global_conf = dedent( - """\ + global_conf = dedent("""\ [codecarbon] force_carbon_intensity_g_co2e_kwh=123.4 - """ - ) + """) with patch("builtins.open", new_callable=get_custom_mock_open(global_conf, "")): with patch("os.path.exists", return_value=True): diff --git a/tests/test_electricitymaps_config_backward_compatibility.py b/tests/test_electricitymaps_config_backward_compatibility.py index 885c8ea4a..c6c328b36 100644 --- a/tests/test_electricitymaps_config_backward_compatibility.py +++ b/tests/test_electricitymaps_config_backward_compatibility.py @@ -16,12 +16,10 @@ class TestConfigBackwardCompatibility(unittest.TestCase): @patch("os.path.exists", return_value=True) def test_old_config_parameter_name(self, mock_exists): """Test that co2_signal_api_token in config file still works.""" - config_with_old_name = dedent( - """\ + config_with_old_name = dedent("""\ [codecarbon] co2_signal_api_token=old-config-token - """ - ) + """) with patch( "builtins.open", new_callable=get_custom_mock_open(config_with_old_name, "") @@ -41,13 +39,11 @@ def test_old_config_parameter_name(self, mock_exists): @patch("os.path.exists", return_value=True) def test_new_config_parameter_takes_precedence(self, mock_exists): """Test that new config parameter takes precedence over old one.""" - config_with_both_names = dedent( - """\ + config_with_both_names = dedent("""\ [codecarbon] electricitymaps_api_token=new-config-token co2_signal_api_token=old-config-token - """ - ) + """) with patch( "builtins.open", diff --git a/tests/test_ram.py b/tests/test_ram.py index 3902a7156..80d0480a3 100644 --- a/tests/test_ram.py +++ b/tests/test_ram.py @@ -49,8 +49,7 @@ def test_ram_diff(self): del array def test_ram_slurm(self): - scontrol_str = dedent( - """\ + scontrol_str = dedent("""\ scontrol show job $SLURM_JOB_ID JobId=XXXX JobName=gpu-jupyterhub UserId=XXXX GroupId=XXXX MCS_label=N/A @@ -80,26 +79,21 @@ def test_ram_slurm(self): StdOut=/linkhome/rech/gendxh01/uei48xr/jupyterhub_slurm.out Power= TresPerNode=gres:gpu:4 - """ - ) + """) ram = RAM(tracking_mode="slurm") ram_size = ram._parse_scontrol(scontrol_str) self.assertEqual(ram_size, "128G") - scontrol_str = dedent( - """\ + scontrol_str = dedent("""\ ReqTRES=cpu=32,mem=134G,node=1,billing=40,gres/gpu=4 AllocTRES=cpu=64,mem=42K,node=1,billing=40,gres/gpu=4 - """ - ) + """) ram = RAM(tracking_mode="slurm") ram_size = ram._parse_scontrol(scontrol_str) self.assertEqual(ram_size, "42K") - scontrol_str = dedent( - """\ + scontrol_str = dedent("""\ TRES=cpu=64,mem=50000M,node=1,billing=40,gres/gpu=4 - """ - ) + """) ram = RAM(tracking_mode="slurm") ram_size = ram._parse_scontrol(scontrol_str) self.assertEqual(ram_size, "50000M") From 2d9d7fb436c634fd8e71758f7a2cfd214b120162 Mon Sep 17 00:00:00 2001 From: benoit-cty <6603048+benoit-cty@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:49:25 +0400 Subject: [PATCH 07/11] Multi-die fix for Windows EMI --- codecarbon/core/windows_emi.py | 94 ++++++++++++++++++++++++++++++++- docs/explanation/methodology.md | 7 +++ tests/test_windows_emi.py | 88 ++++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 1 deletion(-) diff --git a/codecarbon/core/windows_emi.py b/codecarbon/core/windows_emi.py index 0a3a58a13..8f770e6d3 100644 --- a/codecarbon/core/windows_emi.py +++ b/codecarbon/core/windows_emi.py @@ -42,6 +42,10 @@ PWH_TO_WH = 1e-12 HNS_TO_S = 1e-7 +# Maximum relative difference between two absolute energy counters for them to +# be considered two views of the same hardware counter. +MIRRORED_CHANNEL_TOLERANCE = 1e-6 + _GENERIC_READ = 0x80000000 _FILE_SHARE_READ = 0x1 _FILE_SHARE_WRITE = 0x2 @@ -274,6 +278,40 @@ def select_channel_indices( return selected +def find_mirrored_channels( + channels: List[Tuple[Tuple[str, int], int]], +) -> List[Tuple[str, int]]: + """ + Identify the channels that report the same underlying energy counter. + + ``channels`` is an ordered list of ``(key, absolute_energy_pwh)``. + + Multi-die CPUs (e.g. AMD Ryzen Threadripper / EPYC) expose one package + channel per die, but every die mirrors the same socket-wide RAPL counter. + Summing those channels multiplies the reported CPU power by the number of + dies, exactly like the ``package-0-die-0`` / ``package-0-die-1`` duplicates + seen through the Linux powercap interface. Two independent meters never + accumulate the very same picowatt-hour count, so equal counters mean + duplicated ones. + + Returns the keys of the channels duplicating an earlier one. + """ + references: List[int] = [] + mirrored: List[Tuple[str, int]] = [] + for key, energy in channels: + if energy <= 0: + continue + for reference in references: + if abs(energy - reference) <= MIRRORED_CHANNEL_TOLERANCE * max( + energy, reference + ): + mirrored.append(key) + break + else: + references.append(energy) + return mirrored + + class WindowsEMI: """ A class to interface the Windows Energy Meter Interface (EMI) for @@ -317,6 +355,7 @@ def _setup_emi(self) -> None: "No Energy Meter Interface device found. EMI requires Windows 11 " "running on bare metal (or a Windows 10 device with metering hardware)." ) + logger.debug("\tEMI - Found %d energy meter device(s)", len(device_paths)) for device_path in device_paths: try: channel_names = _read_device_channels(device_path) @@ -325,6 +364,11 @@ def _setup_emi(self) -> None: "\tEMI - Unable to read metadata of %s: %s", device_path, e ) continue + logger.debug( + "\tEMI - Device %s exposes the channel(s) %s", + device_path, + channel_names, + ) selected = select_channel_indices(channel_names, self.emi_include_dram) if not selected: continue @@ -354,12 +398,54 @@ def _snapshot(self) -> Dict[Tuple[str, int], Tuple[int, int]]: snapshot[(device_path, index)] = measurements[index] return snapshot + def _drop_mirrored_channels( + self, snapshot: Dict[Tuple[str, int], Tuple[int, int]] + ) -> None: + """ + Stop monitoring the channels that duplicate the counter of another one, + as summing them would over-count the CPU power. + """ + channels = [] + for device_path, channel_names, selected in self._devices: + for index in selected: + # DRAM channels are a different measurement, never a duplicate + if "dram" in channel_names[index].lower(): + continue + measurement = snapshot.get((device_path, index)) + if measurement is not None: + channels.append(((device_path, index), measurement[0])) + if len(channels) < 2: + return + mirrored = set(find_mirrored_channels(channels)) + if not mirrored: + return + devices = [] + for device_path, channel_names, selected in self._devices: + kept = [] + for index in selected: + if (device_path, index) in mirrored: + logger.warning( + "\tEMI - Channel '%s' of device %s reports the same energy " + "counter as another channel, it is ignored to avoid " + "double-counting the CPU power (multi-die CPUs mirror the " + "package counter on each die).", + channel_names[index], + device_path, + ) + else: + kept.append(index) + if kept: + devices.append((device_path, channel_names, kept)) + self._devices = devices + def start(self) -> None: """ Starts monitoring CPU energy consumption by taking the initial counter snapshot. """ - self._last_measurements = self._snapshot() + snapshot = self._snapshot() + self._drop_mirrored_channels(snapshot) + self._last_measurements = snapshot def get_cpu_details(self, duration: Time) -> Dict: """ @@ -389,6 +475,12 @@ def get_cpu_details(self, duration: Time) -> Dict: "\tEMI - Skipping negative delta on channel '%s'", channel_names[index], ) + logger.debug( + "\tEMI - Channel '%s' of device %s: %.2f W", + channel_names[index], + device_path, + power_w, + ) name = f"Processor Energy Delta_{channel_index}(kWh)" cpu_details[name] = energy_kwh # We fake the names used by Power Gadget, as IntelRAPL does diff --git a/docs/explanation/methodology.md b/docs/explanation/methodology.md index e247816e8..3bf94519b 100644 --- a/docs/explanation/methodology.md +++ b/docs/explanation/methodology.md @@ -216,6 +216,13 @@ are needed. as the Surface Book). On virtual machines or older Windows versions, CodeCarbon falls back to the CPU-load estimation mode described below. +*Note*: as on Linux, only package channels are measured (the `PP0`/`PP1` +subdomains are subsets of the package and would be counted twice). On +multi-die CPUs (e.g. AMD Ryzen Threadripper) every die mirrors the same +socket-wide package counter, so CodeCarbon keeps only one of them: +summing them would multiply the reported CPU power by the number of +dies. + Legacy support for `Intel Power Gadget` is kept for machines where it is still installed, but the tool [has been discontinued by Intel](https://github.com/mlco2/codecarbon/issues/457). diff --git a/tests/test_windows_emi.py b/tests/test_windows_emi.py index 01647662c..5cbd9ed02 100644 --- a/tests/test_windows_emi.py +++ b/tests/test_windows_emi.py @@ -11,6 +11,7 @@ EMI_VERSION_V2, WindowsEMI, clear_emi_cache, + find_mirrored_channels, is_emi_available, parse_channel_names, parse_measurements, @@ -87,6 +88,32 @@ def test_multiple_packages(self): self.assertEqual(select_channel_indices(names), [0, 1]) +class TestFindMirroredChannels(unittest.TestCase): + def test_identical_counters_are_mirrored(self): + channels = [(("dev0", 0), 5_000_000_000), (("dev1", 0), 5_000_000_000)] + self.assertEqual(find_mirrored_channels(channels), [("dev1", 0)]) + + def test_negligible_difference_is_mirrored(self): + channels = [(("dev0", 0), 1_000_000_000_000), (("dev1", 0), 1_000_000_100_000)] + self.assertEqual(find_mirrored_channels(channels), [("dev1", 0)]) + + def test_distinct_counters_are_kept(self): + channels = [(("dev0", 0), 5_000_000_000), (("dev1", 0), 4_000_000_000)] + self.assertEqual(find_mirrored_channels(channels), []) + + def test_only_the_first_channel_is_kept(self): + channels = [ + (("dev0", 0), 7_000_000_000), + (("dev0", 1), 7_000_000_000), + (("dev0", 2), 7_000_000_000), + ] + self.assertEqual(find_mirrored_channels(channels), [("dev0", 1), ("dev0", 2)]) + + def test_empty_counters_are_ignored(self): + channels = [(("dev0", 0), 0), (("dev1", 0), 0)] + self.assertEqual(find_mirrored_channels(channels), []) + + @mock.patch("codecarbon.core.windows_emi.list_emi_device_paths", return_value=["dev0"]) @mock.patch("codecarbon.core.windows_emi._read_device_channels", return_value=CHANNELS) @mock.patch.object(sys, "platform", "win32") @@ -174,6 +201,67 @@ def test_include_dram_channel(self, mock_channels, mock_paths): self.assertIn("Processor Energy Delta_1(kWh)", details) +PACKAGE_CHANNEL = ["RAPL_Package0_PKG"] + + +@mock.patch( + "codecarbon.core.windows_emi.list_emi_device_paths", + return_value=["die0", "die1"], +) +@mock.patch( + "codecarbon.core.windows_emi._read_device_channels", + return_value=PACKAGE_CHANNEL, +) +@mock.patch.object(sys, "platform", "win32") +class TestMultiDieDeduplication(unittest.TestCase): + """ + Multi-die CPUs (e.g. AMD Ryzen Threadripper) mirror the same socket-wide + package counter on every die: summing them would multiply the CPU power. + """ + + def test_mirrored_channels_are_dropped_at_start(self, mock_channels, mock_paths): + emi = WindowsEMI() + self.assertEqual(len(emi._devices), 2) + with mock.patch( + "codecarbon.core.windows_emi._read_device_measurements", + side_effect=[[(5_000_000_000, 0)], [(5_000_000_000, 0)]], + ): + emi.start() + + self.assertEqual(emi._devices, [("die0", PACKAGE_CHANNEL, [0])]) + + def test_power_is_not_doubled(self, mock_channels, mock_paths): + emi = WindowsEMI() + # Both dies report the very same counter, before and after the delta + with mock.patch( + "codecarbon.core.windows_emi._read_device_measurements", + side_effect=[ + [(5_000_000_000, 0)], + [(5_000_000_000, 0)], + [(6_000_000_000, 10_000_000)], + [(6_000_000_000, 10_000_000)], + ], + ): + emi.start() + details = emi.get_cpu_details(Time(seconds=1)) + + self.assertNotIn("Processor Power Delta_1(kWh)", details) + self.assertAlmostEqual(details["Processor Power Delta_0(kWh)"], 3.6) + + def test_distinct_counters_are_all_monitored(self, mock_channels, mock_paths): + emi = WindowsEMI() + with mock.patch( + "codecarbon.core.windows_emi._read_device_measurements", + side_effect=[[(5_000_000_000, 0)], [(4_000_000_000, 0)]], + ): + emi.start() + + self.assertEqual( + emi._devices, + [("die0", PACKAGE_CHANNEL, [0]), ("die1", PACKAGE_CHANNEL, [0])], + ) + + class _ValueRef: """Stand-in for a ctypes integer passed with byref().""" From 7a6c51e1610212f613f3f01dca311da3c086ffdb Mon Sep 17 00:00:00 2001 From: benoit-cty <6603048+benoit-cty@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:58:53 +0400 Subject: [PATCH 08/11] Pin isort to a non-beta version --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b2d81156a..c9de61764 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: --expand-star-imports, ] - repo: https://github.com/pycqa/isort - rev: 9.0.0b1 + rev: 8.0.1 hooks: - id: isort args: ["--filter-files"] From b3898db77d353682a3dbdde83321a6a82016a594 Mon Sep 17 00:00:00 2001 From: benoit-cty <6603048+benoit-cty@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:02:31 +0400 Subject: [PATCH 09/11] =?UTF-8?q?Fix=20Hardware=20cache=20loses=20rapl=5Fi?= =?UTF-8?q?nclude=5Fdram=20for=20EMI=20=E2=80=94=20hardware=5Fcache.py:132?= =?UTF-8?q?=20only=20reads=20the=20DRAM/psys=20settings=20when=20hw.=5Fmod?= =?UTF-8?q?e=20=3D=3D=20"intel=5Frapl".=20A=20cached=20windows=5Femi=20CPU?= =?UTF-8?q?=20is=20rebuilt=20with=20rapl=5Finclude=5Fdram=3DFalse,=20silen?= =?UTF-8?q?tly=20dropping=20the=20user's=20setting=20on=20the=20second=20r?= =?UTF-8?q?un.=20Also,=20WindowsEMI=20stores=20it=20as=20emi=5Finclude=5Fd?= =?UTF-8?q?ram,=20so=20even=20adding=20the=20mode=20wouldn't=20match=20the?= =?UTF-8?q?=20getattr(intel,=20"rapl=5Finclude=5Fdram")=20lookup.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- codecarbon/core/hardware_cache.py | 4 ++++ tests/test_hardware_cache.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/codecarbon/core/hardware_cache.py b/codecarbon/core/hardware_cache.py index 829de0813..3c661fe07 100644 --- a/codecarbon/core/hardware_cache.py +++ b/codecarbon/core/hardware_cache.py @@ -134,6 +134,10 @@ def _spec_from_hardware(hw) -> Dict[str, Any]: spec["rapl_include_dram"] = getattr(intel, "rapl_include_dram", False) spec["rapl_prefer_psys"] = getattr(intel, "rapl_prefer_psys", False) spec["rapl_dir"] = getattr(intel, "_lin_rapl_dir", DEFAULT_RAPL_DIR) + elif hw._mode == "windows_emi" and hasattr(hw, "_intel_interface"): + spec["rapl_include_dram"] = getattr( + hw._intel_interface, "emi_include_dram", False + ) return spec if kind == HardwareKind.APPLE_CHIP: return { diff --git a/tests/test_hardware_cache.py b/tests/test_hardware_cache.py index 7fa5a0f66..9b80164a1 100644 --- a/tests/test_hardware_cache.py +++ b/tests/test_hardware_cache.py @@ -138,6 +138,24 @@ def test_spec_from_hardware_intel_rapl_cpu(): assert spec["rapl_dir"] == "/sys/class/powercap/intel-rapl/subsystem" +def test_spec_from_hardware_windows_emi_cpu(): + cpu_hw = type( + "CPU", + (), + { + "_mode": "windows_emi", + "_model": "Intel CPU", + "_tdp": 65, + "_tracking_mode": "machine", + "_intel_interface": SimpleNamespace(emi_include_dram=True), + }, + )() + spec = hardware_cache._spec_from_hardware(cpu_hw) + assert spec["rapl_include_dram"] is True + assert spec["rapl_prefer_psys"] is False + assert "rapl_dir" not in spec + + def test_spec_and_rebuild_roundtrip_for_apple_chip(): spec = {"kind": "apple_chip", "model": "Apple M1", "chip_part": "CPU"} fake_chip = SimpleNamespace(_model="Apple M1") From 61e3552a08e632cba130da1b5ed38b0c92312409 Mon Sep 17 00:00:00 2001 From: benoit-cty <6603048+benoit-cty@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:20:34 +0400 Subject: [PATCH 10/11] =?UTF-8?q?Mirrored-channel=20detection=20now=20conf?= =?UTF-8?q?irms=20over=20a=20measurement=20interval=20=E2=80=94=20windows?= =?UTF-8?q?=5Femi.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit find_mirrored_channels returns a {channel: mirrored_reference} mapping instead of a bare list, so each candidate keeps a link to the channel it duplicates. start() only flags candidates (_mirrored_candidates) and leaves them out of the reported energy; it no longer prunes _devices. _confirm_mirrored_channels() runs at the start of each get_cpu_details(), before the deltas are computed: equal deltas over the interval → permanently dropped (_drop_channels); diverging deltas → the channel is a real independent meter and is restored (logged at INFO); both deltas zero → inconclusive, stays pending for the next interval. Net effect: a dual-socket machine whose two counters happen to coincide at the initial snapshot recovers its second socket after one interval, while multi-die duplicates are still excluded from the very first measurement (no over-count regression). --- codecarbon/core/windows_emi.py | 111 ++++++++++++++++++++++++++------ codecarbon/emissions_tracker.py | 11 ++-- docs/explanation/methodology.md | 4 +- docs/how-to/configuration.md | 29 +++++++++ tests/test_windows_emi.py | 78 ++++++++++++++++++++-- 5 files changed, 201 insertions(+), 32 deletions(-) diff --git a/codecarbon/core/windows_emi.py b/codecarbon/core/windows_emi.py index 8f770e6d3..479b66449 100644 --- a/codecarbon/core/windows_emi.py +++ b/codecarbon/core/windows_emi.py @@ -278,9 +278,14 @@ def select_channel_indices( return selected +def _counters_match(first: int, second: int) -> bool: + """Tell whether two counter values are indistinguishable.""" + return abs(first - second) <= MIRRORED_CHANNEL_TOLERANCE * max(first, second) + + def find_mirrored_channels( channels: List[Tuple[Tuple[str, int], int]], -) -> List[Tuple[str, int]]: +) -> Dict[Tuple[str, int], Tuple[str, int]]: """ Identify the channels that report the same underlying energy counter. @@ -290,25 +295,24 @@ def find_mirrored_channels( channel per die, but every die mirrors the same socket-wide RAPL counter. Summing those channels multiplies the reported CPU power by the number of dies, exactly like the ``package-0-die-0`` / ``package-0-die-1`` duplicates - seen through the Linux powercap interface. Two independent meters never - accumulate the very same picowatt-hour count, so equal counters mean - duplicated ones. + seen through the Linux powercap interface. Two independent meters are + extremely unlikely to hold the very same picowatt-hour count, so equal + counters point at duplicated ones. - Returns the keys of the channels duplicating an earlier one. + Returns a mapping of each duplicating channel to the earlier channel it + mirrors. """ - references: List[int] = [] - mirrored: List[Tuple[str, int]] = [] + references: List[Tuple[Tuple[str, int], int]] = [] + mirrored: Dict[Tuple[str, int], Tuple[str, int]] = {} for key, energy in channels: if energy <= 0: continue - for reference in references: - if abs(energy - reference) <= MIRRORED_CHANNEL_TOLERANCE * max( - energy, reference - ): - mirrored.append(key) + for reference_key, reference in references: + if _counters_match(energy, reference): + mirrored[key] = reference_key break else: - references.append(energy) + references.append((key, energy)) return mirrored @@ -343,6 +347,10 @@ def __init__(self, emi_include_dram: bool = False): self._devices: List[Tuple[str, List[str], List[int]]] = [] # (device_path, channel_index) -> (absolute_energy_pwh, absolute_time_100ns) self._last_measurements: Dict[Tuple[str, int], Tuple[int, int]] = {} + # Channels that look like a duplicate of another one, but whose energy + # deltas have not confirmed it yet. They are left out of the + # measurement while pending. + self._mirrored_candidates: Dict[Tuple[str, int], Tuple[str, int]] = {} self._cpu_details: Dict = {} self._setup_emi() @@ -398,11 +406,11 @@ def _snapshot(self) -> Dict[Tuple[str, int], Tuple[int, int]]: snapshot[(device_path, index)] = measurements[index] return snapshot - def _drop_mirrored_channels( + def _find_mirrored_candidates( self, snapshot: Dict[Tuple[str, int], Tuple[int, int]] - ) -> None: + ) -> Dict[Tuple[str, int], Tuple[str, int]]: """ - Stop monitoring the channels that duplicate the counter of another one, + Flag the channels whose counter is indistinguishable from another one's, as summing them would over-count the CPU power. """ channels = [] @@ -415,15 +423,72 @@ def _drop_mirrored_channels( if measurement is not None: channels.append(((device_path, index), measurement[0])) if len(channels) < 2: + return {} + return find_mirrored_channels(channels) + + def _channel_name(self, key: Tuple[str, int]) -> str: + for device_path, channel_names, _ in self._devices: + if device_path == key[0]: + return channel_names[key[1]] + return str(key) + + def _energy_delta( + self, + key: Tuple[str, int], + snapshot: Dict[Tuple[str, int], Tuple[int, int]], + ): + """Energy accumulated by a channel since the previous snapshot.""" + current = snapshot.get(key) + previous = self._last_measurements.get(key) + if current is None or previous is None: + return None + return current[0] - previous[0] + + def _confirm_mirrored_channels( + self, snapshot: Dict[Tuple[str, int], Tuple[int, int]] + ) -> None: + """ + Decide the fate of the channels flagged as duplicates at ``start()``. + + Two independent meters could hold indistinguishable counters at the + single instant of the initial snapshot, so a candidate is only dropped + for good once it has also accumulated the very same energy over a + measurement interval. A candidate whose energy delta differs is a real + meter and is restored, and a candidate that has not accumulated + anything yet stays pending until an interval is conclusive. + """ + if not self._mirrored_candidates: return - mirrored = set(find_mirrored_channels(channels)) - if not mirrored: - return + pending: Dict[Tuple[str, int], Tuple[str, int]] = {} + confirmed: List[Tuple[str, int]] = [] + for key, reference_key in self._mirrored_candidates.items(): + delta = self._energy_delta(key, snapshot) + reference_delta = self._energy_delta(reference_key, snapshot) + if delta is None or reference_delta is None: + pending[key] = reference_key + elif delta <= 0 and reference_delta <= 0: + # Nothing was accumulated: the interval is not conclusive + pending[key] = reference_key + elif _counters_match(delta, reference_delta): + confirmed.append(key) + else: + logger.info( + "\tEMI - Channel '%s' accumulates energy on its own, it is " + "an independent meter and is measured again.", + self._channel_name(key), + ) + self._mirrored_candidates = pending + if confirmed: + self._drop_channels(confirmed) + + def _drop_channels(self, keys: List[Tuple[str, int]]) -> None: + """Stop monitoring the given channels.""" + dropped = set(keys) devices = [] for device_path, channel_names, selected in self._devices: kept = [] for index in selected: - if (device_path, index) in mirrored: + if (device_path, index) in dropped: logger.warning( "\tEMI - Channel '%s' of device %s reports the same energy " "counter as another channel, it is ignored to avoid " @@ -444,7 +509,7 @@ def start(self) -> None: counter snapshot. """ snapshot = self._snapshot() - self._drop_mirrored_channels(snapshot) + self._mirrored_candidates = self._find_mirrored_candidates(snapshot) self._last_measurements = snapshot def get_cpu_details(self, duration: Time) -> Dict: @@ -454,10 +519,14 @@ def get_cpu_details(self, duration: Time) -> Dict: """ cpu_details: Dict = {} snapshot = self._snapshot() + self._confirm_mirrored_channels(snapshot) channel_index = 0 for device_path, channel_names, selected in self._devices: for index in selected: key = (device_path, index) + if key in self._mirrored_candidates: + # Still suspected of duplicating another channel + continue current = snapshot.get(key) previous = self._last_measurements.get(key) energy_kwh = 0.0 diff --git a/codecarbon/emissions_tracker.py b/codecarbon/emissions_tracker.py index 270723466..96ed00c91 100644 --- a/codecarbon/emissions_tracker.py +++ b/codecarbon/emissions_tracker.py @@ -509,9 +509,11 @@ def __init__( :param force_mode_cpu_load: Force the addition of a CPU in MODE_CPU_LOAD :param allow_multiple_runs: Allow multiple CodeCarbon instances on the same machine. Defaults to True since v3 (was False in v2). - :param rapl_include_dram: Include DRAM (memory) power in RAPL measurements on Linux, - defaults to False. When True, measures CPU package + DRAM. - Only affects systems where RAPL exposes separate DRAM domains. + :param rapl_include_dram: Include DRAM (memory) power in the counter-based CPU + measurements, defaults to False. When True, measures + CPU package + DRAM. Applies to the Linux RAPL interface + and to the Windows Energy Meter Interface, on systems + exposing separate DRAM domains/channels. :param rapl_prefer_psys: Prefer psys (platform) RAPL domain over package domains on Linux, defaults to False. When True, uses total platform power (CPU + chipset + PCIe). When False, uses package domains which @@ -1587,7 +1589,8 @@ def track_emissions( litres of water consumed per kilowatt-hour of electricity consumed. :param force_carbon_intensity_g_co2e_kwh: Override grid carbon intensity in gCO2e/kWh for emissions calculations. - :param rapl_include_dram: Include DRAM in RAPL measurements on Linux (default: False). + :param rapl_include_dram: Include DRAM in the counter-based CPU measurements + (Linux RAPL and Windows EMI, default: False). When True, measures CPU package + DRAM. :param rapl_prefer_psys: Prefer psys over package domains for RAPL on Linux (default: False). When True, uses total platform power. diff --git a/docs/explanation/methodology.md b/docs/explanation/methodology.md index 3bf94519b..bbe94b841 100644 --- a/docs/explanation/methodology.md +++ b/docs/explanation/methodology.md @@ -221,7 +221,9 @@ subdomains are subsets of the package and would be counted twice). On multi-die CPUs (e.g. AMD Ryzen Threadripper) every die mirrors the same socket-wide package counter, so CodeCarbon keeps only one of them: summing them would multiply the reported CPU power by the number of -dies. +dies. The `DRAM` channels are excluded as well, unless the +[`rapl_include_dram`](../how-to/configuration.md#including-dram-in-the-cpu-measurement) +option is enabled. Legacy support for `Intel Power Gadget` is kept for machines where it is still installed, but the tool [has been discontinued by diff --git a/docs/how-to/configuration.md b/docs/how-to/configuration.md index f5b2835a7..9f6766aa1 100644 --- a/docs/how-to/configuration.md +++ b/docs/how-to/configuration.md @@ -149,6 +149,35 @@ EmissionsTracker(tracking_mode="process") `"process"` mode gives a lower-bound estimate of your code's footprint. `"machine"` mode is more conservative and accounts for all activity on the system. +## Including DRAM in the CPU Measurement + +When CodeCarbon reads the CPU energy counters, the hardware also exposes a +`DRAM` domain measuring the memory controller. It is **excluded by default**, so +that memory power is reported by the RAM tracker only and is not counted twice. + +Set `rapl_include_dram` to add it to the CPU measurement: + +``` ini +[codecarbon] +rapl_include_dram = true +``` + +Or in code: + +``` python +EmissionsTracker(rapl_include_dram=True) +``` + +Despite its name, this option applies to every counter-based CPU interface: + +- **Linux**: the `dram` domains of the [RAPL](../explanation/rapl.md) powercap + interface. +- **Windows 11**: the `DRAM` channels of the Energy Meter Interface, which + exposes the very same RAPL counters. + +It has no effect when CodeCarbon falls back to TDP/CPU-load estimation, since +that mode models the CPU package only. + ## Access internet through proxy server If you need a proxy to access internet, which is needed to call a Web diff --git a/tests/test_windows_emi.py b/tests/test_windows_emi.py index 5cbd9ed02..55ada4fff 100644 --- a/tests/test_windows_emi.py +++ b/tests/test_windows_emi.py @@ -91,15 +91,15 @@ def test_multiple_packages(self): class TestFindMirroredChannels(unittest.TestCase): def test_identical_counters_are_mirrored(self): channels = [(("dev0", 0), 5_000_000_000), (("dev1", 0), 5_000_000_000)] - self.assertEqual(find_mirrored_channels(channels), [("dev1", 0)]) + self.assertEqual(find_mirrored_channels(channels), {("dev1", 0): ("dev0", 0)}) def test_negligible_difference_is_mirrored(self): channels = [(("dev0", 0), 1_000_000_000_000), (("dev1", 0), 1_000_000_100_000)] - self.assertEqual(find_mirrored_channels(channels), [("dev1", 0)]) + self.assertEqual(find_mirrored_channels(channels), {("dev1", 0): ("dev0", 0)}) def test_distinct_counters_are_kept(self): channels = [(("dev0", 0), 5_000_000_000), (("dev1", 0), 4_000_000_000)] - self.assertEqual(find_mirrored_channels(channels), []) + self.assertEqual(find_mirrored_channels(channels), {}) def test_only_the_first_channel_is_kept(self): channels = [ @@ -107,11 +107,14 @@ def test_only_the_first_channel_is_kept(self): (("dev0", 1), 7_000_000_000), (("dev0", 2), 7_000_000_000), ] - self.assertEqual(find_mirrored_channels(channels), [("dev0", 1), ("dev0", 2)]) + self.assertEqual( + find_mirrored_channels(channels), + {("dev0", 1): ("dev0", 0), ("dev0", 2): ("dev0", 0)}, + ) def test_empty_counters_are_ignored(self): channels = [(("dev0", 0), 0), (("dev1", 0), 0)] - self.assertEqual(find_mirrored_channels(channels), []) + self.assertEqual(find_mirrored_channels(channels), {}) @mock.patch("codecarbon.core.windows_emi.list_emi_device_paths", return_value=["dev0"]) @@ -219,7 +222,7 @@ class TestMultiDieDeduplication(unittest.TestCase): package counter on every die: summing them would multiply the CPU power. """ - def test_mirrored_channels_are_dropped_at_start(self, mock_channels, mock_paths): + def test_mirrored_channels_are_flagged_at_start(self, mock_channels, mock_paths): emi = WindowsEMI() self.assertEqual(len(emi._devices), 2) with mock.patch( @@ -228,8 +231,70 @@ def test_mirrored_channels_are_dropped_at_start(self, mock_channels, mock_paths) ): emi.start() + # Flagged, hence left out of the measurement, but not dropped until an + # interval has confirmed that both counters accumulate identically + self.assertEqual(emi._mirrored_candidates, {("die1", 0): ("die0", 0)}) + self.assertEqual(len(emi._devices), 2) + + def test_mirrored_channels_are_dropped_once_confirmed( + self, mock_channels, mock_paths + ): + emi = WindowsEMI() + with mock.patch( + "codecarbon.core.windows_emi._read_device_measurements", + side_effect=[ + [(5_000_000_000, 0)], + [(5_000_000_000, 0)], + [(6_000_000_000, 10_000_000)], + [(6_000_000_000, 10_000_000)], + ], + ): + emi.start() + emi.get_cpu_details(Time(seconds=1)) + + self.assertEqual(emi._mirrored_candidates, {}) self.assertEqual(emi._devices, [("die0", PACKAGE_CHANNEL, [0])]) + def test_flagged_channel_stays_pending_until_conclusive( + self, mock_channels, mock_paths + ): + emi = WindowsEMI() + with mock.patch( + "codecarbon.core.windows_emi._read_device_measurements", + side_effect=[ + [(5_000_000_000, 0)], + [(5_000_000_000, 0)], + # Neither counter moved: nothing can be concluded yet + [(5_000_000_000, 10_000_000)], + [(5_000_000_000, 10_000_000)], + ], + ): + emi.start() + emi.get_cpu_details(Time(seconds=1)) + + self.assertEqual(emi._mirrored_candidates, {("die1", 0): ("die0", 0)}) + self.assertEqual(len(emi._devices), 2) + + def test_independent_meter_is_restored(self, mock_channels, mock_paths): + emi = WindowsEMI() + with mock.patch( + "codecarbon.core.windows_emi._read_device_measurements", + side_effect=[ + # Both counters happen to be equal at the initial snapshot... + [(5_000_000_000, 0)], + [(5_000_000_000, 0)], + # ...but they accumulate energy independently + [(6_000_000_000, 10_000_000)], + [(5_500_000_000, 10_000_000)], + ], + ): + emi.start() + details = emi.get_cpu_details(Time(seconds=1)) + + self.assertEqual(emi._mirrored_candidates, {}) + self.assertEqual(len(emi._devices), 2) + self.assertIn("Processor Power Delta_1(kWh)", details) + def test_power_is_not_doubled(self, mock_channels, mock_paths): emi = WindowsEMI() # Both dies report the very same counter, before and after the delta @@ -256,6 +321,7 @@ def test_distinct_counters_are_all_monitored(self, mock_channels, mock_paths): ): emi.start() + self.assertEqual(emi._mirrored_candidates, {}) self.assertEqual( emi._devices, [("die0", PACKAGE_CHANNEL, [0]), ("die1", PACKAGE_CHANNEL, [0])], From 495d735782007faafc227cb7a7caf93a56a5b4bc Mon Sep 17 00:00:00 2001 From: benoit-cty <6603048+benoit-cty@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:49:27 +0400 Subject: [PATCH 11/11] Fix multiple-counting --- codecarbon/core/windows_emi.py | 80 ++++++++++++++++++++++----------- docs/explanation/methodology.md | 15 ++++--- examples/README.md | 1 + examples/emi_channels.py | 75 +++++++++++++++++++++++++++++++ tests/test_windows_emi.py | 45 ++++++++++++++++--- 5 files changed, 178 insertions(+), 38 deletions(-) create mode 100644 examples/emi_channels.py diff --git a/codecarbon/core/windows_emi.py b/codecarbon/core/windows_emi.py index 479b66449..d4bd0d07c 100644 --- a/codecarbon/core/windows_emi.py +++ b/codecarbon/core/windows_emi.py @@ -250,32 +250,56 @@ def _read_device_measurements( return parse_measurements(raw) -def select_channel_indices( - channel_names: List[str], include_dram: bool = False -) -> List[int]: +def _is_package_channel(name: str) -> bool: + return "pkg" in name.lower() + + +def _is_dram_channel(name: str) -> bool: + return "dram" in name.lower() + + +def select_channels( + device_channels: List[Tuple[str, List[str]]], include_dram: bool = False +) -> Dict[str, List[int]]: """ - Select the channels to monitor among the ones exposed by a device. + Select the channels to monitor among all the ones exposed by the machine. - Package channels (e.g. ``RAPL_Package0_PKG``) are preferred: they measure - the whole CPU package, while PP0/PP1 are subdomains of the package and - would double-count. If no package channel is identified, all channels are - used, as with unknown RAPL domains on Linux. + The selection has to be made globally, over every device at once: Windows + exposes one EMI device per metered component, so a CPU typically appears as + one device per core (``RAPL_Package0_Core3_CORE``) alongside the device + holding the package channel (``RAPL_Package0_PKG``). Package channels + measure the whole CPU package, while the CORE/PP0/PP1 channels are + subdomains of that package: measuring both would count the same energy + twice. + + When no package channel is exposed at all, every channel is kept, as with + unknown RAPL domains on Linux. + + Returns a mapping of device path to the indices of its selected channels. """ - selected = [i for i, name in enumerate(channel_names) if "pkg" in name.lower()] - if not selected: - if len(channel_names) == 1: - selected = [0] - else: - logger.warning( - "\tEMI - No package channel identified among %s, using all channels", - channel_names, - ) - return list(range(len(channel_names))) - if include_dram: - selected += [ - i for i, name in enumerate(channel_names) if "dram" in name.lower() - ] - return selected + has_package = any( + _is_package_channel(name) for _, names in device_channels for name in names + ) + if not has_package: + logger.warning( + "\tEMI - No package channel identified among %s, using all channels", + [name for _, names in device_channels for name in names], + ) + selection: Dict[str, List[int]] = {} + for device_path, channel_names in device_channels: + selected = [] + for index, name in enumerate(channel_names): + if _is_dram_channel(name): + keep = include_dram + elif has_package: + keep = _is_package_channel(name) + else: + keep = True + if keep: + selected.append(index) + if selected: + selection[device_path] = selected + return selection def _counters_match(first: int, second: int) -> bool: @@ -364,6 +388,7 @@ def _setup_emi(self) -> None: "running on bare metal (or a Windows 10 device with metering hardware)." ) logger.debug("\tEMI - Found %d energy meter device(s)", len(device_paths)) + device_channels: List[Tuple[str, List[str]]] = [] for device_path in device_paths: try: channel_names = _read_device_channels(device_path) @@ -377,11 +402,14 @@ def _setup_emi(self) -> None: device_path, channel_names, ) - selected = select_channel_indices(channel_names, self.emi_include_dram) + device_channels.append((device_path, channel_names)) + selection = select_channels(device_channels, self.emi_include_dram) + for device_path, channel_names in device_channels: + selected = selection.get(device_path) if not selected: continue for index in selected: - logger.info( + logger.debug( "\tEMI - Monitoring channel '%s' of device %s", channel_names[index], device_path, @@ -417,7 +445,7 @@ def _find_mirrored_candidates( for device_path, channel_names, selected in self._devices: for index in selected: # DRAM channels are a different measurement, never a duplicate - if "dram" in channel_names[index].lower(): + if _is_dram_channel(channel_names[index]): continue measurement = snapshot.get((device_path, index)) if measurement is not None: diff --git a/docs/explanation/methodology.md b/docs/explanation/methodology.md index bbe94b841..fb51122d0 100644 --- a/docs/explanation/methodology.md +++ b/docs/explanation/methodology.md @@ -216,12 +216,15 @@ are needed. as the Surface Book). On virtual machines or older Windows versions, CodeCarbon falls back to the CPU-load estimation mode described below. -*Note*: as on Linux, only package channels are measured (the `PP0`/`PP1` -subdomains are subsets of the package and would be counted twice). On -multi-die CPUs (e.g. AMD Ryzen Threadripper) every die mirrors the same -socket-wide package counter, so CodeCarbon keeps only one of them: -summing them would multiply the reported CPU power by the number of -dies. The `DRAM` channels are excluded as well, unless the +*Note*: as on Linux, only package channels are measured. Windows exposes +one EMI device per metered component, so a CPU shows up as one device per +core (`RAPL_Package0_Core3_CORE`) next to the device holding the package +channel (`RAPL_Package0_PKG`). Those per-core channels, like `PP0`/`PP1`, +are subdomains of the package: measuring both would count the same energy +twice, so CodeCarbon keeps the package channels only. On multi-die CPUs +where every die mirrors the same socket-wide counter, the duplicates are +detected and dropped as well. The `DRAM` channels are excluded too, unless +the [`rapl_include_dram`](../how-to/configuration.md#including-dram-in-the-cpu-measurement) option is enabled. diff --git a/examples/README.md b/examples/README.md index dfba6754d..3e6e022b1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,4 +15,5 @@ pip install -r examples/requirements-examples.txt * [mnist-comet.py](mnist-comet.py): Using `CO2Tracker` with [`Comet`](https://www.comet.ml/site) for automatic experiment and emissions tracking. * [api_call_demo.py](api_call_demo.py): Simplest demo to send computer emissions to CodeCarbon API. * [api_call_debug.py](api_call_debug.py): Script to send computer emissions to CodeCarbon API. Made for debugging: debug log and send data every 20 seconds. +* [emi_channels.py](emi_channels.py): Print every channel exposed by the Windows Energy Meter Interface and the power each of them reports, to debug CPU power measurement on Windows. * And many more in the [examples](../examples) folder. diff --git a/examples/emi_channels.py b/examples/emi_channels.py new file mode 100644 index 000000000..7ce63b08c --- /dev/null +++ b/examples/emi_channels.py @@ -0,0 +1,75 @@ +""" +Print every channel exposed by the Windows Energy Meter Interface (EMI), and +the power each of them reports, to diagnose CPU power measurements. + +Usage: + python examples/emi_channels.py [duration_in_seconds] +""" + +import sys +import time + +from codecarbon.core import windows_emi + + +def main(duration: float = 5.0) -> int: + print(f"windows_emi loaded from : {windows_emi.__file__}") + print( + f"mirrored channel detection: {hasattr(windows_emi, 'find_mirrored_channels')}" + ) + + device_paths = windows_emi.list_emi_device_paths() + print(f"energy meter device(s) : {len(device_paths)}") + if not device_paths: + print("\nEMI is not available on this machine.") + return 1 + + channels = {} + for device_path in device_paths: + channels[device_path] = windows_emi._read_device_channels(device_path) + selection = windows_emi.select_channels(list(channels.items())) + for device_path, names in channels.items(): + selected = selection.get(device_path, []) + print(f"\n{device_path}") + for index, name in enumerate(names): + state = "measured" if index in selected else "ignored" + print(f" [{index}] {name} ({state})") + + first = { + path: windows_emi._read_device_measurements(path, len(names)) + for path, names in channels.items() + } + time.sleep(duration) + second = { + path: windows_emi._read_device_measurements(path, len(names)) + for path, names in channels.items() + } + + print(f"\nPower measured over {duration} s:") + total = 0.0 + for device_path, names in channels.items(): + selected = selection.get(device_path, []) + for index, name in enumerate(names): + energy_before, time_before = first[device_path][index] + energy_after, time_after = second[device_path][index] + delta_pwh = energy_after - energy_before + delta_s = (time_after - time_before) * windows_emi.HNS_TO_S or duration + watts = delta_pwh * windows_emi.PWH_TO_WH * 3600 / delta_s + state = "measured" if index in selected else "ignored " + print( + f" [{index}] {state} {name}: " + f"counter {energy_before} -> {energy_after} = {watts:.2f} W" + ) + if index in selected: + total += watts + + print(f"\nSum of the measured channels: {total:.2f} W") + print( + "Compare it with the TDP of your CPU: a value far above it means the " + "same energy is counted several times." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main(float(sys.argv[1]) if len(sys.argv) > 1 else 5.0)) diff --git a/tests/test_windows_emi.py b/tests/test_windows_emi.py index 55ada4fff..5b77b67ac 100644 --- a/tests/test_windows_emi.py +++ b/tests/test_windows_emi.py @@ -15,7 +15,7 @@ is_emi_available, parse_channel_names, parse_measurements, - select_channel_indices, + select_channels, ) from codecarbon.external.hardware import CPU @@ -72,20 +72,53 @@ def test_parse_measurements(self): class TestEmiChannelSelection(unittest.TestCase): def test_selects_package_channel_only(self): - self.assertEqual(select_channel_indices(CHANNELS), [0]) + self.assertEqual(select_channels([("dev0", CHANNELS)]), {"dev0": [0]}) def test_selects_dram_when_requested(self): - self.assertEqual(select_channel_indices(CHANNELS, include_dram=True), [0, 1]) + self.assertEqual( + select_channels([("dev0", CHANNELS)], include_dram=True), + {"dev0": [0, 1]}, + ) def test_single_unknown_channel_is_used(self): - self.assertEqual(select_channel_indices(["CPU Meter"]), [0]) + self.assertEqual(select_channels([("dev0", ["CPU Meter"])]), {"dev0": [0]}) def test_no_package_channel_uses_all(self): - self.assertEqual(select_channel_indices(["a", "b"]), [0, 1]) + self.assertEqual(select_channels([("dev0", ["a", "b"])]), {"dev0": [0, 1]}) def test_multiple_packages(self): names = ["RAPL_Package0_PKG", "RAPL_Package1_PKG", "RAPL_Package0_PP0"] - self.assertEqual(select_channel_indices(names), [0, 1]) + self.assertEqual(select_channels([("dev0", names)]), {"dev0": [0, 1]}) + + def test_per_core_devices_are_ignored_when_a_package_exists(self): + """ + Windows exposes one device per core: those CORE channels are subdomains + of the package and must not be added to it. + """ + device_channels = [ + ("core6", ["RAPL_Package0_Core6_CORE"]), + ("package", ["RAPL_Package0_PKG", "RAPL_Package0_Core0_CORE"]), + ("core1", ["RAPL_Package0_Core1_CORE"]), + ] + self.assertEqual(select_channels(device_channels), {"package": [0]}) + + def test_per_core_devices_are_used_without_a_package(self): + device_channels = [ + ("core0", ["RAPL_Package0_Core0_CORE"]), + ("core1", ["RAPL_Package0_Core1_CORE"]), + ] + self.assertEqual(select_channels(device_channels), {"core0": [0], "core1": [0]}) + + def test_dram_only_device_is_dropped(self): + device_channels = [ + ("package", ["RAPL_Package0_PKG"]), + ("dram", ["RAPL_Package0_DRAM"]), + ] + self.assertEqual(select_channels(device_channels), {"package": [0]}) + self.assertEqual( + select_channels(device_channels, include_dram=True), + {"package": [0], "dram": [0]}, + ) class TestFindMirroredChannels(unittest.TestCase):