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/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0037266fd..c9de61764 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: 8.0.1 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] diff --git a/codecarbon/core/hardware_cache.py b/codecarbon/core/hardware_cache.py index 6970a1b64..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 { @@ -238,6 +242,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/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/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..d4bd0d07c --- /dev/null +++ b/codecarbon/core/windows_emi.py @@ -0,0 +1,625 @@ +""" +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 + +# 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 +_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 _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 all the ones exposed by the machine. + + 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. + """ + 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: + """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]], +) -> Dict[Tuple[str, int], 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 are + extremely unlikely to hold the very same picowatt-hour count, so equal + counters point at duplicated ones. + + Returns a mapping of each duplicating channel to the earlier channel it + mirrors. + """ + 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_key, reference in references: + if _counters_match(energy, reference): + mirrored[key] = reference_key + break + else: + references.append((key, energy)) + return mirrored + + +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]] = {} + # 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() + + 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)." + ) + 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) + except (OSError, ValueError) as e: + logger.debug( + "\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, + ) + 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.debug( + "\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 _find_mirrored_candidates( + self, snapshot: Dict[Tuple[str, int], Tuple[int, int]] + ) -> Dict[Tuple[str, int], Tuple[str, int]]: + """ + Flag the channels whose counter is indistinguishable from another one's, + 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 _is_dram_channel(channel_names[index]): + continue + measurement = snapshot.get((device_path, index)) + 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 + 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 dropped: + 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. + """ + snapshot = self._snapshot() + self._mirrored_candidates = self._find_mirrored_candidates(snapshot) + self._last_measurements = 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() + 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 + 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], + ) + 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 + 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/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/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/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/docs/explanation/methodology.md b/docs/explanation/methodology.md index 968d55dc1..fb51122d0 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,38 @@ 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. + +*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. + +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 +315,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/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/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/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_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_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_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") 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") 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..5b77b67ac --- /dev/null +++ b/tests/test_windows_emi.py @@ -0,0 +1,672 @@ +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, + EMI_VERSION_V2, + WindowsEMI, + clear_emi_cache, + find_mirrored_channels, + is_emi_available, + parse_channel_names, + parse_measurements, + select_channels, +) +from codecarbon.external.hardware import CPU + +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("