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 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 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("