From d8409ae0605b7489689c841cf86949692e4d2c1a Mon Sep 17 00:00:00 2001 From: benoit-cty <4-benoit-cty@users.noreply.git.leximpact.dev> Date: Wed, 11 Dec 2024 14:06:26 +0100 Subject: [PATCH 01/90] build on codecarbon_v3_rc --- .github/workflows/package.yml | 2 +- .github/workflows/pre-commit.yml | 2 +- .github/workflows/release-drafter.yml | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 8ccb29f0f..d82cf3611 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -93,4 +93,4 @@ jobs: run: mamba install --channel file:///tmp/conda-bld --channel codecarbon codecarbon - name: Test conda package shell: bash -l {0} - run: codecarbon --help \ No newline at end of file + run: codecarbon --help diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 22d6cf1ac..2bec7494c 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -3,7 +3,7 @@ name: pre-commit on: pull_request: push: - branches: [master] + branches: [master, codecarbon_v3_rc] jobs: pre-commit: diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 6a9005065..53df8c870 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -3,8 +3,7 @@ name: Release Drafter on: push: # branches to consider in the event; optional, defaults to all - branches: - - master + branches: [master, codecarbon_v3_rc] jobs: update_release_draft: From a31f4bd6bde5c10f5e2082c60c38a05042acad03 Mon Sep 17 00:00:00 2001 From: benoit-cty <4-benoit-cty@users.noreply.git.leximpact.dev> Date: Wed, 11 Dec 2024 14:06:50 +0100 Subject: [PATCH 02/90] bump --- codecarbon/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codecarbon/_version.py b/codecarbon/_version.py index e897069b0..528787cfc 100644 --- a/codecarbon/_version.py +++ b/codecarbon/_version.py @@ -1 +1 @@ -__version__ = "2.8.4" +__version__ = "3.0.0" From b3bf6224b626f4eede5553576469d594b00bb1ac Mon Sep 17 00:00:00 2001 From: benoit-cty <4-benoit-cty@users.noreply.git.leximpact.dev> Date: Wed, 11 Dec 2024 14:17:27 +0100 Subject: [PATCH 03/90] hatch run dev:bumpver update --set-version 3.0.0-rc0 --tag-num --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bbb1ceb14..0bedaea16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -185,8 +185,8 @@ include = [ ] [tool.bumpver] -current_version = "2.8.4" -version_pattern = "MAJOR.MINOR.PATCH" +current_version = "3.0.0-rc0" +version_pattern = "MAJOR.MINOR.PATCH[-TAGNUM]" [tool.bumpver.file_patterns] "codecarbon/_version.py" = [ From b8e1a9a141db08eba1cde908d2bd671591cc0c0b Mon Sep 17 00:00:00 2001 From: benoit-cty <6603048+benoit-cty@users.noreply.github.com> Date: Mon, 13 Jun 2022 22:44:40 +0200 Subject: [PATCH 04/90] Add CPU load tracking --- codecarbon/core/cpu.py | 16 +++++++++ codecarbon/emissions_tracker.py | 2 +- codecarbon/external/hardware.py | 11 +++++- tests/test_hardware.py | 63 +++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 tests/test_hardware.py diff --git a/codecarbon/core/cpu.py b/codecarbon/core/cpu.py index 539da19c7..726836070 100644 --- a/codecarbon/core/cpu.py +++ b/codecarbon/core/cpu.py @@ -12,6 +12,7 @@ import pandas as pd from rapidfuzz import fuzz, process, utils +import psutil from codecarbon.core.rapl import RAPLFile from codecarbon.core.units import Time @@ -58,6 +59,21 @@ def is_rapl_available() -> bool: return False +def is_psutil_available(): + try: + cpu_load = psutil.cpu_percent(interval=0.1) + if cpu_load > 0.0: + return True + else: + return False + except Exception as e: + logger.debug( + "Not using the psutil interface, an exception occurred while instantiating " + + f"psutil.cpu_percent : {e}", + ) + return False + + class IntelPowerGadget: """ A class to interface with Intel Power Gadget for monitoring CPU power consumption on Windows and (non-Apple Silicon) macOS. diff --git a/codecarbon/emissions_tracker.py b/codecarbon/emissions_tracker.py index 4950864bc..954e5bb8d 100644 --- a/codecarbon/emissions_tracker.py +++ b/codecarbon/emissions_tracker.py @@ -20,7 +20,7 @@ from codecarbon.core.units import Energy, Power, Time from codecarbon.core.util import count_cpus, suppress from codecarbon.external.geography import CloudMetadata, GeoMetadata -from codecarbon.external.hardware import CPU, GPU, RAM, AppleSiliconChip +from codecarbon.external.hardware import CPU, GPU, MODE_CPU_LOAD, RAM, AppleSiliconChip from codecarbon.external.logger import logger, set_logger_format, set_logger_level from codecarbon.external.scheduler import PeriodicScheduler from codecarbon.external.task import Task diff --git a/codecarbon/external/hardware.py b/codecarbon/external/hardware.py index 846f859e4..db7dbdb6e 100644 --- a/codecarbon/external/hardware.py +++ b/codecarbon/external/hardware.py @@ -25,6 +25,8 @@ B_TO_GB = 1024 * 1024 * 1024 +MODE_CPU_LOAD = "cpu_load" + @dataclass class BaseHardware(ABC): @@ -174,7 +176,10 @@ def _get_power_from_cpus(self) -> Power: Get CPU power :return: power in kW """ - if self._mode == "constant": + if self._mode == MODE_CPU_LOAD: + power = self._tdp * psutil.cpu_percent(interval=None) + return Power.from_watts(power) + elif self._mode == "constant": power = self._tdp * CONSUMPTION_PERCENTAGE_CONSTANT return Power.from_watts(power) if self._mode == "intel_rapl": @@ -222,6 +227,10 @@ def measure_power_and_energy(self, last_duration: float) -> Tuple[Power, Energy] def start(self): if self._mode in ["intel_power_gadget", "intel_rapl", "apple_powermetrics"]: self._intel_interface.start() + if self._mode == MODE_CPU_LOAD: + # The first time this is called it will return a meaningless 0.0 value which you are supposed to ignore. + psutil.cpu_percent(interval=None) + pass def get_model(self): return self._model diff --git a/tests/test_hardware.py b/tests/test_hardware.py new file mode 100644 index 000000000..53aac01fd --- /dev/null +++ b/tests/test_hardware.py @@ -0,0 +1,63 @@ +import unittest +from time import sleep +from unittest import mock + +from codecarbon.emissions_tracker import OfflineEmissionsTracker +from codecarbon.external.hardware import CPU, GPU, MODE_CPU_LOAD +from tests.testdata import TWO_GPU_DETAILS_RESPONSE + + +@mock.patch("codecarbon.core.cpu.is_psutil_available", return_value=True) +@mock.patch("codecarbon.core.cpu.is_powergadget_available", return_value=False) +@mock.patch("codecarbon.core.cpu.is_rapl_available", return_value=False) +class TestCPULoad(unittest.TestCase): + def test_cpu_total_power( + self, + mocked_is_psutil_available, + mocked_is_powergadget_available, + mocked_is_rapl_available, + ): + cpu = CPU.from_utils( + None, MODE_CPU_LOAD, "Intel(R) Core(TM) i7-7600U CPU @ 2.80GHz", 100 + ) + cpu.start() + sleep(0.5) + self.assertGreater(cpu.total_power().W, 1) + + def test_cpu_load_detection( + self, + mocked_is_psutil_available, + mocked_is_powergadget_available, + mocked_is_rapl_available, + ): + tracker = OfflineEmissionsTracker() + for hardware in tracker._hardware: + if isinstance(hardware, CPU) and hardware._mode == MODE_CPU_LOAD: + break + else: + raise Exception("No CPU load !!!") + tracker.start() + sleep(0.5) + emission = tracker.stop() + self.assertGreater(emission, 0.0) + + +@mock.patch("codecarbon.core.gpu.is_gpu_details_available", return_value=True) +@mock.patch( + "codecarbon.external.hardware.get_gpu_details", + return_value=TWO_GPU_DETAILS_RESPONSE, +) +class TestGPUMetadata(unittest.TestCase): + def test_gpu_metadata_total_power( + self, mocked_get_gpu_details, mocked_is_gpu_details_available + ): + gpu = GPU.from_utils() + self.assertAlmostEqual(0.074318, gpu.total_power().kW, places=2) + + def test_gpu_metadata_one_gpu_power( + self, mocked_get_gpu_details, mocked_is_gpu_details_available + ): + gpu = GPU.from_utils() + self.assertAlmostEqual( + 0.032159, gpu._get_power_for_gpus(gpu_ids=[1]).kW, places=2 + ) From 455cb6ba4f87cd25d7b0074cbdc4c2b1983ee3e1 Mon Sep 17 00:00:00 2001 From: benoit-cty <6603048+benoit-cty@users.noreply.github.com> Date: Tue, 14 Jun 2022 06:56:59 +0200 Subject: [PATCH 05/90] Add new CPU --- codecarbon/data/hardware/cpu_power.csv | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codecarbon/data/hardware/cpu_power.csv b/codecarbon/data/hardware/cpu_power.csv index a97fc457a..f9094cb24 100644 --- a/codecarbon/data/hardware/cpu_power.csv +++ b/codecarbon/data/hardware/cpu_power.csv @@ -2232,6 +2232,7 @@ Intel Core i7-1185G7E,28 Intel Core i7-1185GRE,28 Intel Core i7-1195G7,28 Intel Core i7-1270P,64 +Intel Core i7-12700K,190 Intel Core i7-1360P,28 Intel Core i7-2600,95 Intel Core i7-2600K,95 @@ -3753,6 +3754,7 @@ Intel Xeon Platinum 8376HL,205 Intel Xeon Platinum 8380,270 Intel Xeon Platinum 8380H,250 Intel Xeon Platinum 8380HL,250 +Intel Xeon Platinum 8370C,205 Intel Xeon Platinum 9221,250 Intel Xeon Platinum 9222,250 Intel Xeon Platinum 9242,350 From b9ce8860e24db0c95c4c5905d4c55f6d13140456 Mon Sep 17 00:00:00 2001 From: benoit-cty <6603048+benoit-cty@users.noreply.github.com> Date: Tue, 14 Jun 2022 21:58:01 +0200 Subject: [PATCH 06/90] Add mandatory country --- tests/test_hardware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_hardware.py b/tests/test_hardware.py index 53aac01fd..d3dfcc791 100644 --- a/tests/test_hardware.py +++ b/tests/test_hardware.py @@ -30,7 +30,7 @@ def test_cpu_load_detection( mocked_is_powergadget_available, mocked_is_rapl_available, ): - tracker = OfflineEmissionsTracker() + tracker = OfflineEmissionsTracker(country_iso_code="FRA") for hardware in tracker._hardware: if isinstance(hardware, CPU) and hardware._mode == MODE_CPU_LOAD: break From 0fb78058e26cac24de25d3ee8bee645ea1e000a5 Mon Sep 17 00:00:00 2001 From: benoit-cty <6603048+benoit-cty@users.noreply.github.com> Date: Tue, 14 Jun 2022 21:59:21 +0200 Subject: [PATCH 07/90] Fix RAPL fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CPU process tracking mode Fix No such file or directory in test Took minimum of 10% TDP WIP: compare RAPL with manual wip : debug rapl wip doc measure_options.drawio Mettre à jour Diagramme sans nom.drawio Mettre à jour Diagramme sans nom.drawio Doc Rename Diagramme sans nom.drawio to cpu_fallback.drawio Add cpu-load to RAPL Fake version Lint Avoid error on deleted lock file Put back move instead of copy for input Fix test Add a force_add_mode_cpu_load --- README.md | 6 ++ codecarbon/core/api_client.py | 4 +- codecarbon/core/cpu.py | 4 +- codecarbon/core/util.py | 8 +- codecarbon/emissions_tracker.py | 13 +++- codecarbon/external/hardware.py | 47 ++++++++++-- docs/edit/api.rst | 13 ++++ docs/edit/cpu_fallback.drawio | 95 ++++++++++++++++++++++++ docs/edit/methodology.rst | 40 ++++++++++ examples/full_cpu.py | 37 +++++++++ examples/test_rapl_calculus.sh | 8 ++ tests/test_core_util.py | 4 +- tests/test_emissions_tracker_constant.py | 6 +- 13 files changed, 265 insertions(+), 20 deletions(-) create mode 100644 docs/edit/cpu_fallback.drawio create mode 100644 examples/full_cpu.py create mode 100644 examples/test_rapl_calculus.sh diff --git a/README.md b/README.md index a71576cb3..6bd7ff939 100644 --- a/README.md +++ b/README.md @@ -172,3 +172,9 @@ Here is a sample for BibTeX: # Contact 📝 Maintainers are [@vict0rsch](https://github.com/vict0rsch) [@benoit-cty](https://github.com/benoit-cty) and [@SaboniAmine](https://github.com/saboniamine). Codecarbon is developed by volunteers from [**Mila**](http://mila.quebec) and the [**DataForGoodFR**](https://twitter.com/dataforgood_fr) community alongside donated professional time of engineers at [**Comet.ml**](https://comet.ml) and [**BCG GAMMA**](https://www.bcg.com/en-nl/beyond-consulting/bcg-gamma/default). + +## Star History + +Comparison of the number of stars accumulated by the different Python CO2 emissions projects: +[![Star History Chart](https://api.star-history.com/svg?repos=mlco2/codecarbon,lfwa/carbontracker,sb-ai-lab/Eco2AI,fvaleye/tracarbon,Breakend/experiment-impact-tracker&type=Date)](https://star-history.com/#mlco2/codecarbon&lfwa/carbontracker&sb-ai-lab/Eco2AI&fvaleye/tracarbon&Breakend/experiment-impact-tracker&Date) + diff --git a/codecarbon/core/api_client.py b/codecarbon/core/api_client.py index 541dfb988..34067c71c 100644 --- a/codecarbon/core/api_client.py +++ b/codecarbon/core/api_client.py @@ -264,8 +264,8 @@ def _create_run(self, experiment_id: str): gpu_count=self.conf.get("gpu_count"), gpu_model=self.conf.get("gpu_model"), # Reduce precision for Privacy - longitude=round(self.conf.get("longitude"), 1), - latitude=round(self.conf.get("latitude"), 1), + longitude=round(self.conf.get("longitude", 0), 1), + latitude=round(self.conf.get("latitude", 0), 1), region=self.conf.get("region"), provider=self.conf.get("provider"), ram_total_size=self.conf.get("ram_total_size"), diff --git a/codecarbon/core/cpu.py b/codecarbon/core/cpu.py index 726836070..6b705f79a 100644 --- a/codecarbon/core/cpu.py +++ b/codecarbon/core/cpu.py @@ -11,8 +11,8 @@ from typing import Dict, Optional, Tuple import pandas as pd -from rapidfuzz import fuzz, process, utils import psutil +from rapidfuzz import fuzz, process, utils from codecarbon.core.rapl import RAPLFile from codecarbon.core.units import Time @@ -310,7 +310,7 @@ def _fetch_rapl_files(self) -> None: logger.debug("We will read Intel RAPL files at %s", rapl_file) except PermissionError as e: raise PermissionError( - "Unable to read Intel RAPL files for CPU power, we will use a constant for your CPU power." + "PermissionError : Unable to read Intel RAPL files for CPU power, we will use a constant for your CPU power." + " Please view https://github.com/mlco2/codecarbon/issues/244" + " for workarounds : %s", e, diff --git a/codecarbon/core/util.py b/codecarbon/core/util.py index d1d6bea41..1e91435ac 100644 --- a/codecarbon/core/util.py +++ b/codecarbon/core/util.py @@ -48,6 +48,8 @@ def resolve_path(path: Union[str, Path]) -> Path: def backup(file_path: Union[str, Path], ext: Optional[str] = ".bak") -> None: """ Resolves the path to a path then backs it up, adding the extension provided. + Warning : this function will rename the file in place, it's the calling function that will write a new file at the original path. + This function will not overwrite existing backups but add a number. Args: file_path (Union[str, Path]): Path to a file to backup. @@ -111,7 +113,7 @@ def count_cpus() -> int: "Error running `scontrol show job $SLURM_JOB_ID` " + "to count SLURM-available cpus. Using the machine's cpu count." ) - return psutil.cpu_count() + return psutil.cpu_count(logical=True) num_cpus_matches = re.findall(r"NumCPUs=\d+", scontrol) @@ -120,14 +122,14 @@ def count_cpus() -> int: "Could not find NumCPUs= after running `scontrol show job $SLURM_JOB_ID` " + "to count SLURM-available cpus. Using the machine's cpu count." ) - return psutil.cpu_count() + return psutil.cpu_count(logical=True) if len(num_cpus_matches) > 1: logger.warning( "Unexpected output after running `scontrol show job $SLURM_JOB_ID` " + "to count SLURM-available cpus. Using the machine's cpu count." ) - return psutil.cpu_count() + return psutil.cpu_count(logical=True) num_cpus = num_cpus_matches[0].replace("NumCPUs=", "") logger.debug(f"Detected {num_cpus} cpus available on SLURM.") diff --git a/codecarbon/emissions_tracker.py b/codecarbon/emissions_tracker.py index 954e5bb8d..da5aaa47d 100644 --- a/codecarbon/emissions_tracker.py +++ b/codecarbon/emissions_tracker.py @@ -170,6 +170,7 @@ def __init__( logger_preamble: Optional[str] = _sentinel, default_cpu_power: Optional[int] = _sentinel, pue: Optional[int] = _sentinel, + force_add_mode_cpu_load: Optional[bool] = _sentinel, allow_multiple_runs: Optional[bool] = _sentinel, ): """ @@ -225,6 +226,7 @@ def __init__( messages. Defaults to "". :param default_cpu_power: cpu power to be used as default if the cpu is not known. :param pue: PUE (Power Usage Effectiveness) of the datacenter. + :param force_add_mode_cpu_load: Force the addition of a CPU in MODE_CPU_LOAD :param allow_multiple_runs: Allow multiple instances of codecarbon running in parallel. Defaults to False. """ @@ -274,6 +276,7 @@ def __init__( self._set_from_conf(logger_preamble, "logger_preamble", "") self._set_from_conf(default_cpu_power, "default_cpu_power") self._set_from_conf(pue, "pue", 1.0, float) + self._set_from_conf(force_add_mode_cpu_load, "force_add_mode_cpu_load", False) self._set_from_conf( experiment_id, "experiment_id", "5b0fa12a-3dd7-45bb-9766-cc326314d9f1" ) @@ -689,8 +692,11 @@ def _do_measurements(self) -> None: self._total_cpu_energy += energy self._cpu_power = power logger.info( - f"Energy consumed for all CPUs : {self._total_cpu_energy.kWh:.6f} kWh" - + f". Total CPU Power : {self._cpu_power.W} W" + f"Delta energy consumed for CPU with {hardware._mode} : {energy.kWh:.6f} kWh" + + f", power : {self._cpu_power.W} W" + ) + logger.info( + f"Energy consumed for All CPU : {self._total_cpu_energy.kWh:.6f} kWh" ) elif isinstance(hardware, GPU): self._total_gpu_energy += energy @@ -725,8 +731,7 @@ def _do_measurements(self) -> None: logger.error(f"Unknown hardware type: {hardware} ({type(hardware)})") h_time = time.perf_counter() - h_time logger.debug( - f"{hardware.__class__.__name__} : {hardware.total_power().W:,.2f} " - + f"W during {last_duration:,.2f} s [measurement time: {h_time:,.4f}]" + f"Done measure for {hardware.__class__.__name__} - measurement time: {h_time:,.4f} s - last call {last_duration:,.2f} s" ) logger.info( f"{self._total_energy.kWh:.6f} kWh of electricity used since the beginning." diff --git a/codecarbon/external/hardware.py b/codecarbon/external/hardware.py index db7dbdb6e..d0dee476d 100644 --- a/codecarbon/external/hardware.py +++ b/codecarbon/external/hardware.py @@ -14,7 +14,7 @@ from codecarbon.core.gpu import AllGPUDevices from codecarbon.core.powermetrics import ApplePowermetrics from codecarbon.core.units import Energy, Power, Time -from codecarbon.core.util import SLURM_JOB_ID, detect_cpu_model +from codecarbon.core.util import SLURM_JOB_ID, count_cpus, detect_cpu_model from codecarbon.external.logger import logger # default W value for a CPU if no model is found in the ref csv @@ -149,12 +149,19 @@ def __init__( model: str, tdp: int, rapl_dir: str = "/sys/class/powercap/intel-rapl", + tracking_mode: str = "machine", ): + assert tracking_mode in ["machine", "process"] self._output_dir = output_dir self._mode = mode self._model = model self._tdp = tdp self._is_generic_tdp = False + self._tracking_mode = tracking_mode + self._pid = psutil.Process().pid + self._cpu_count = count_cpus() + self._process = psutil.Process(self._pid) + if self._mode == "intel_power_gadget": self._intel_interface = IntelPowerGadget(self._output_dir) elif self._mode == "intel_rapl": @@ -171,14 +178,36 @@ def __repr__(self) -> str: return s + ")" + def _get_power_from_cpu_load(self): + """ + When in MODE_CPU_LOAD + """ + if self._tracking_mode == "machine": + cpu_load = psutil.cpu_percent(interval=None) + # We add a minimum of 10% of TDP + power = max(self._tdp * 0.1, self._tdp * cpu_load / 100) + logger.debug( + f"CPU load {self._tdp} W x {cpu_load}% = {power} for whole machine." + ) + elif self._tracking_mode == "process": + + cpu_load = self._process.cpu_percent(interval=None) / self._cpu_count + power = self._tdp * cpu_load / 100 + logger.debug( + f"CPU load {self._tdp} W x {cpu_load}% = {power} for process {self._pid}." + ) + else: + raise Exception(f"Unknown tracking_mode {self._tracking_mode}") + return Power.from_watts(power) + def _get_power_from_cpus(self) -> Power: """ Get CPU power :return: power in kW """ if self._mode == MODE_CPU_LOAD: - power = self._tdp * psutil.cpu_percent(interval=None) - return Power.from_watts(power) + power = self._get_power_from_cpu_load() + return power elif self._mode == "constant": power = self._tdp * CONSUMPTION_PERCENTAGE_CONSTANT return Power.from_watts(power) @@ -229,8 +258,7 @@ def start(self): self._intel_interface.start() if self._mode == MODE_CPU_LOAD: # The first time this is called it will return a meaningless 0.0 value which you are supposed to ignore. - psutil.cpu_percent(interval=None) - pass + _ = self._get_power_from_cpu_load() def get_model(self): return self._model @@ -242,6 +270,7 @@ def from_utils( mode: str, model: Optional[str] = None, tdp: Optional[int] = None, + tracking_mode: str = "machine", ) -> "CPU": if model is None: model = detect_cpu_model() @@ -254,7 +283,13 @@ def from_utils( cpu._is_generic_tdp = True return cpu - return cls(output_dir=output_dir, mode=mode, model=model, tdp=tdp) + return cls( + output_dir=output_dir, + mode=mode, + model=model, + tdp=tdp, + tracking_mode=tracking_mode, + ) @dataclass diff --git a/docs/edit/api.rst b/docs/edit/api.rst index 2fa9023f6..b1b263015 100644 --- a/docs/edit/api.rst +++ b/docs/edit/api.rst @@ -10,6 +10,19 @@ CodeCarbon API .. warning:: This mode use the CodeCarbon API to upload the timeseries of your emissions on a central server. All data will be public! + +.. image:: https://github.com/mlco2/codecarbon/blob/master/carbonserver/Images/code_carbon_archi.png + :align: center + :alt: Summary + :height: 400px + :width: 700px + +.. image:: https://github.com/mlco2/codecarbon/raw/master/carbonserver/Images/CodecarbonDB.jpg + :align: center + :alt: Summary + :height: 400px + :width: 700px + Before using it, you need an experiment_id, to get one, run: .. code-block:: console diff --git a/docs/edit/cpu_fallback.drawio b/docs/edit/cpu_fallback.drawio new file mode 100644 index 000000000..62cc866f1 --- /dev/null +++ b/docs/edit/cpu_fallback.drawio @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/edit/methodology.rst b/docs/edit/methodology.rst index d9aa29650..01d655fc7 100644 --- a/docs/edit/methodology.rst +++ b/docs/edit/methodology.rst @@ -133,6 +133,46 @@ If none of the tracking tools are available on a computing resource, CodeCarbon The net Energy Used is the net power supply consumed during the compute time, measured as ``kWh``. +CPU hardware +------------ + +The CPU die is the processing unit itself. It’s a piece of semiconductor that has been sculpted/etched/deposited by various manufacturing processes into a net of logic blocks that do stuff that makes computing possible1. The processor package is what you get when you buy a single processor. It contains one or more dies, plastic/ceramic housing for dies and gold-plated contacts that match those on your motherboard. + +In Linux kernel, energy_uj is a current energy counter in micro joules. It is used to measure CPU cores’ energy consumption. + +Micro joules is then converted in kWh, with formulas kWh=energy * 10 ** (-6) * 2.77778e-7 + +For example, on a laptop with Intel(R) Core(TM) i7-7600U, Code Carbon will read two files : +/sys/class/powercap/intel-rapl/intel-rapl:1/energy_uj and /sys/class/powercap/intel-rapl/intel-rapl:0/energy_uj + + +RAPL Metrics +------------ +RAPL stand for Running Average Power Limit, it is a feature of processors (CPU) that provide the energy consumption of the processor. + +See https://blog.chih.me/read-cpu-power-with-RAPL.html + +Despite the name Intel RAPL, it support AMD processors since kernel 5.8. + +Metric comparison + +Desktop computer with AMD Ryzen Threadripper 1950X 16-Core (32 threads) Processor. +Power plug measure when idle (10% CPU): 125 W +package-0-die-0 : 68 W +package-0-die-1 : 68 W +CodeCarbon : 137 W + +Power plug measure when loaded (100% CPU): 256 W - 125W in idle = 131 W +CorWatt PkgWatt + 133.13 169.82 + 7.54 169.82 +CodeCarbon : 330 W +package-0-die-0 : 166 W +package-0-die-1 : 166 W + +RAPL: 234 sec. Joule Counter Range, at 280 Watts + + ``Energy = Power * Time`` References diff --git a/examples/full_cpu.py b/examples/full_cpu.py new file mode 100644 index 000000000..f1b4cfa57 --- /dev/null +++ b/examples/full_cpu.py @@ -0,0 +1,37 @@ +import multiprocessing + +from codecarbon import EmissionsTracker + +# pool = SafePool(multiprocessing.cpu_count(), retries=150) +# handles = { +# pool.submit(_preprocess, page): #LAMBDA FONCTION A APPLIQUER +# } +# results = [] +# failures = [] +# for result in pool.results(): +# i = handles[result.handle] +# results.append((i, result.value)) +# if not result.ok(): +# failures.append(result.value) + +# if failures: +# raise failures.pop() + + +def task(number): + a = 0 + for i in range(1000): + for i in range(int(1e6)): + a = a + i**number + + +tracker = EmissionsTracker(measure_power_secs=10, force_add_mode_cpu_load=True) +try: + tracker.start() + with multiprocessing.Pool() as pool: + # call the function for each item in parallel + pool.map(task, [i for i in range(100)]) +finally: + emissions: float = tracker.stop() + +print(f"Emissions: {emissions} kg") diff --git a/examples/test_rapl_calculus.sh b/examples/test_rapl_calculus.sh new file mode 100644 index 000000000..1e14d770b --- /dev/null +++ b/examples/test_rapl_calculus.sh @@ -0,0 +1,8 @@ +#!/bin/bash +find /sys/class/powercap/intel-rapl/* -name energy_uj -exec bash -c "echo {} && cat {}" \; +# cat /sys/class/powercap/intel-rapl/intel-rapl:1/energy_uj +# cat /sys/class/powercap/intel-rapl/intel-rapl:0/energy_uj +python full_cpu.py +find /sys/class/powercap/intel-rapl/* -name energy_uj -exec bash -c "echo {} && cat {}" \; +# cat /sys/class/powercap/intel-rapl/intel-rapl:1/energy_uj +# cat /sys/class/powercap/intel-rapl/intel-rapl:0/energy_uj diff --git a/tests/test_core_util.py b/tests/test_core_util.py index 413f9d8e4..ef76fe27e 100644 --- a/tests/test_core_util.py +++ b/tests/test_core_util.py @@ -1,4 +1,4 @@ -import os +import shutil import tempfile from codecarbon.core.util import backup, resolve_path @@ -11,7 +11,7 @@ def test_backup(): assert expected_backup_path.exists() # re-create file and back it up again second_file = tempfile.NamedTemporaryFile() - os.rename(second_file.name, first_file.name) + shutil.copyfile(second_file.name, first_file.name) backup(first_file.name) backup_of_backup_path = resolve_path(f"{first_file.name}_0.bak") assert backup_of_backup_path.exists() diff --git a/tests/test_emissions_tracker_constant.py b/tests/test_emissions_tracker_constant.py index 50a052fad..610cc3d47 100644 --- a/tests/test_emissions_tracker_constant.py +++ b/tests/test_emissions_tracker_constant.py @@ -64,11 +64,15 @@ def test_carbon_tracker_offline_constant(self): self.verify_output_file(self.emissions_file_path) @mock.patch.object(cpu.TDP, "_get_cpu_power_from_registry") - def test_carbon_tracker_offline_constant_default_cpu_power(self, mock_tdp): + @mock.patch.object(cpu, "is_psutil_available") + def test_carbon_tracker_offline_constant_default_cpu_power( + self, mock_tdp, mock_psutil + ): # Same as test_carbon_tracker_offline_constant test but this time forcing the default cpu power USER_INPUT_CPU_POWER = 1_000 # Mock the output of tdp mock_tdp.return_value = None + mock_psutil.return_value = False tracker = OfflineEmissionsTracker( country_iso_code="USA", output_dir=self.emissions_path, From 747637f9738f2562f4ffc1eb78c6200ed4a3f329 Mon Sep 17 00:00:00 2001 From: benoit-cty <4-benoit-cty@users.noreply.git.leximpact.dev> Date: Tue, 26 Nov 2024 18:13:00 +0100 Subject: [PATCH 08/90] debug RAPL --- examples/intel_rapl_show.py | 223 ++++++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 examples/intel_rapl_show.py diff --git a/examples/intel_rapl_show.py b/examples/intel_rapl_show.py new file mode 100644 index 000000000..c3f0241d9 --- /dev/null +++ b/examples/intel_rapl_show.py @@ -0,0 +1,223 @@ +# This script demonstrates how to read power consumption using Intel RAPL (Running Average Power Limit) on Linux. +# It also list available power domains available on the system, like package (entire CPU), cores, uncore (RAM, cache), and platform +# The script can be used to monitor power consumption over time for a specific power domain +# The power consumption is read from the energy counter in microjoules and converted to watts + +import json +import os +import time + + +class RAPLDomainInspector: + def __init__(self): + self.rapl_base_path = "/sys/class/powercap/intel-rapl" + + def inspect_rapl_domains(self): + """ + Thoroughly inspect RAPL domains with detailed information + """ + domain_details = {} + + try: + # Iterate through all RAPL domains + for domain_dir in os.listdir(self.rapl_base_path): + if not domain_dir.startswith("intel-rapl:"): + continue + + domain_path = os.path.join(self.rapl_base_path, domain_dir) + domain_info = {"name": domain_dir, "files": {}, "subdomain_details": {}} + + # Check available files in the domain + for file in os.listdir(domain_path): + try: + file_path = os.path.join(domain_path, file) + if os.path.isfile(file_path): + with open(file_path, "r") as f: + domain_info["files"][file] = f.read().strip() + except Exception as e: + domain_info["files"][file] = f"Error reading: {e}" + + # Check subdomains + subdomains_path = os.path.join(domain_path, "subdomains") + if os.path.exists(subdomains_path): + for subdomain in os.listdir(subdomains_path): + subdomain_full_path = os.path.join(subdomains_path, subdomain) + subdomain_info = {} + + for file in os.listdir(subdomain_full_path): + try: + file_path = os.path.join(subdomain_full_path, file) + if os.path.isfile(file_path): + with open(file_path, "r") as f: + subdomain_info[file] = f.read().strip() + except Exception as e: + subdomain_info[file] = f"Error reading: {e}" + + domain_info["subdomain_details"][subdomain] = subdomain_info + + domain_details[domain_dir] = domain_info + + except Exception as e: + print(f"Error inspecting RAPL domains: {e}") + + return domain_details + + def identify_potential_ram_domains(self, domain_details): + """ + Identify potential RAM-related domains based on name and characteristics + + Sample Detailed RAPL Domain Information: + { + "intel-rapl:1": { + "name": "intel-rapl:1", + "files": { + "uevent": "", + "energy_uj": "10359908363", + "enabled": "0", + "name": "package-0-die-1", + "max_energy_range_uj": "65532610987" + }, + "subdomain_details": {} + }, + "intel-rapl:0": { + "name": "intel-rapl:0", + "files": { + "uevent": "", + "energy_uj": "10360237493", + "enabled": "0", + "name": "package-0-die-0", + "max_energy_range_uj": "65532610987" + }, + "subdomain_details": {} + } + } + """ + potential_ram_domains = [] + + for domain_name, domain_info in domain_details.items(): + # Check domain names that might indicate memory + memory_indicators = [ + "dram", + "uncore", + "ram", + "memory", + "dimm", # Common alternative identifiers + ] + + is_potential_ram = any( + indicator in domain_name.lower() for indicator in memory_indicators + ) + + if is_potential_ram: + potential_ram_domains.append( + {"domain": domain_name, "details": domain_info} + ) + + return potential_ram_domains + + +class IntelRAPL: + def __init__(self): + # Base path for RAPL power readings in sysfs + self.rapl_base_path = "/sys/class/powercap/intel-rapl" + + def list_power_domains(self): + """ + List available RAPL power domains + """ + domains = [] + try: + for domain in os.listdir(self.rapl_base_path): + if domain.startswith("intel-rapl:"): + domains.append(domain) + return domains + except Exception as e: + print(f"Error listing power domains: {e}") + return [] + + def read_power_consumption(self, domain=None, interval=1): + """ + Read power consumption for a specific RAPL domain + + :param domain: Specific power domain to read (e.g., 'intel-rapl:0') + :param interval: Time interval for power calculation + :return: Power consumption in watts + """ + if not domain: + # If no domain specified, use the first available + domains = self.list_power_domains() + if not domains: + print("No RAPL domains found") + return None + domain = domains[0] + + try: + # Path to energy counter + energy_path = os.path.join(self.rapl_base_path, domain, "energy_uj") + + # Read initial energy + with open(energy_path, "r") as f: + initial_energy = int(f.read().strip()) + + # Wait for the specified interval + time.sleep(interval) + + # Read energy again + with open(energy_path, "r") as f: + final_energy = int(f.read().strip()) + + # Calculate power: (energy difference in microjoules) / (interval in seconds) / 1,000,000 + power = (final_energy - initial_energy) / (interval * 1_000_000) + + return power + + except Exception as e: + print(f"Error reading power for {domain}: {e}") + return None + + def monitor_power(self, interval=1, duration=10): + """ + Monitor power consumption over time + + :param interval: Sampling interval in seconds + :param duration: Total monitoring duration in seconds + """ + print("Starting Power Monitoring:") + start_time = time.time() + + while time.time() - start_time < duration: + power = self.read_power_consumption() + if power is not None: + print(f"Power Consumption: {power:.2f} Watts") + + time.sleep(interval) + + +# Example usage +if __name__ == "__main__": + inspector = RAPLDomainInspector() + + # Get detailed RAPL domain information + domain_details = inspector.inspect_rapl_domains() + + # Pretty print full domain details + print("Detailed RAPL Domain Information:") + print(json.dumps(domain_details, indent=2)) + + # Identify potential RAM domains + potential_ram_domains = inspector.identify_potential_ram_domains(domain_details) + + print("\nPotential RAM Domains:") + for domain in potential_ram_domains: + print(f"Domain: {domain['domain']}") + print("Key Files:") + for file, value in domain["details"]["files"].items(): + print(f" {file}: {value}") + print("---") + rapl = IntelRAPL() + + # List available power domains + print("Available Power Domains:") + + # Monitor power consumption + rapl.monitor_power(interval=1, duration=5) From 7118d1e2fa9219029a8acb0858cd893566cdbb7e Mon Sep 17 00:00:00 2001 From: benoit-cty <4-benoit-cty@users.noreply.git.leximpact.dev> Date: Wed, 11 Dec 2024 19:58:55 +0100 Subject: [PATCH 09/90] Release Drafter --- .github/workflows/release-drafter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 53df8c870..96f659e53 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -10,6 +10,6 @@ jobs: runs-on: ubuntu-latest steps: # Drafts your next Release notes as Pull Requests are merged into "master" - - uses: release-drafter/release-drafter@v5.7.0 + - uses: release-drafter/release-drafter@v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From dca73f3a9ab1518f8e6999d23110fdc94e079445 Mon Sep 17 00:00:00 2001 From: benoit-cty <4-benoit-cty@users.noreply.git.leximpact.dev> Date: Wed, 11 Dec 2024 22:31:32 +0100 Subject: [PATCH 10/90] refacto ResourceTracker --- codecarbon/core/resource_tracker.py | 14 +++++++++----- codecarbon/emissions_tracker.py | 8 ++++---- tests/test_emissions_tracker_constant.py | 6 ++++-- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/codecarbon/core/resource_tracker.py b/codecarbon/core/resource_tracker.py index 4ae910852..66ac6a144 100644 --- a/codecarbon/core/resource_tracker.py +++ b/codecarbon/core/resource_tracker.py @@ -4,7 +4,7 @@ from codecarbon.core import cpu, gpu, powermetrics from codecarbon.core.config import parse_gpu_ids from codecarbon.core.util import detect_cpu_model, is_linux_os, is_mac_os, is_windows_os -from codecarbon.external.hardware import CPU, GPU, RAM, AppleSiliconChip +from codecarbon.external.hardware import CPU, GPU, MODE_CPU_LOAD, RAM, AppleSiliconChip from codecarbon.external.logger import logger @@ -133,10 +133,14 @@ def set_GPU_tracking(self): else: logger.info("No GPU found.") - def set_CPU_GPU_ram_tracking(self): - self.set_RAM_tracking() - self.set_CPU_tracking() - self.set_GPU_tracking() + def set_CPU_GPU_ram_tracking(self, tracker): + """ + Set up CPU, GPU and RAM tracking based on the user's configuration. + param tracker: BaseEmissionsTracker object + """ + self.set_RAM_tracking(tracker) + self.set_CPU_tracking(tracker) + self.set_GPU_tracking(tracker) logger.debug( f"""The below tracking methods have been set up: diff --git a/codecarbon/emissions_tracker.py b/codecarbon/emissions_tracker.py index da5aaa47d..988760433 100644 --- a/codecarbon/emissions_tracker.py +++ b/codecarbon/emissions_tracker.py @@ -20,7 +20,7 @@ from codecarbon.core.units import Energy, Power, Time from codecarbon.core.util import count_cpus, suppress from codecarbon.external.geography import CloudMetadata, GeoMetadata -from codecarbon.external.hardware import CPU, GPU, MODE_CPU_LOAD, RAM, AppleSiliconChip +from codecarbon.external.hardware import CPU, GPU, RAM, AppleSiliconChip from codecarbon.external.logger import logger, set_logger_format, set_logger_level from codecarbon.external.scheduler import PeriodicScheduler from codecarbon.external.task import Task @@ -170,7 +170,7 @@ def __init__( logger_preamble: Optional[str] = _sentinel, default_cpu_power: Optional[int] = _sentinel, pue: Optional[int] = _sentinel, - force_add_mode_cpu_load: Optional[bool] = _sentinel, + force_mode_cpu_load: Optional[bool] = _sentinel, allow_multiple_runs: Optional[bool] = _sentinel, ): """ @@ -226,7 +226,7 @@ def __init__( messages. Defaults to "". :param default_cpu_power: cpu power to be used as default if the cpu is not known. :param pue: PUE (Power Usage Effectiveness) of the datacenter. - :param force_add_mode_cpu_load: Force the addition of a CPU in MODE_CPU_LOAD + :param force_mode_cpu_load: Force the addition of a CPU in MODE_CPU_LOAD :param allow_multiple_runs: Allow multiple instances of codecarbon running in parallel. Defaults to False. """ @@ -276,7 +276,7 @@ def __init__( self._set_from_conf(logger_preamble, "logger_preamble", "") self._set_from_conf(default_cpu_power, "default_cpu_power") self._set_from_conf(pue, "pue", 1.0, float) - self._set_from_conf(force_add_mode_cpu_load, "force_add_mode_cpu_load", False) + self._set_from_conf(force_mode_cpu_load, "force_mode_cpu_load", False) self._set_from_conf( experiment_id, "experiment_id", "5b0fa12a-3dd7-45bb-9766-cc326314d9f1" ) diff --git a/tests/test_emissions_tracker_constant.py b/tests/test_emissions_tracker_constant.py index 610cc3d47..7fd4655c1 100644 --- a/tests/test_emissions_tracker_constant.py +++ b/tests/test_emissions_tracker_constant.py @@ -118,10 +118,12 @@ def test_carbon_tracker_offline_region_error(self): output_file=self.emissions_file, ) tracker.start() - tracker._measure_power_and_energy() - cloud: CloudMetadata = tracker._get_cloud_metadata() try: + with self.assertRaises(ValueError) as context: + tracker._measure_power_and_energy() + self.assertTrue("Unable to find country name" in context.exception.args[0]) + cloud: CloudMetadata = tracker._get_cloud_metadata() with self.assertRaises(ValueError) as context: tracker._emissions.get_cloud_country_iso_code(cloud) self.assertTrue("Unable to find country name" in context.exception.args[0]) From 59b3a15b463ff00933bdfc4c61387fa7cd4dc93b Mon Sep 17 00:00:00 2001 From: benoit-cty <4-benoit-cty@users.noreply.git.leximpact.dev> Date: Thu, 12 Dec 2024 21:24:44 +0100 Subject: [PATCH 11/90] example --- examples/full_cpu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/full_cpu.py b/examples/full_cpu.py index f1b4cfa57..27cb457b9 100644 --- a/examples/full_cpu.py +++ b/examples/full_cpu.py @@ -25,7 +25,7 @@ def task(number): a = a + i**number -tracker = EmissionsTracker(measure_power_secs=10, force_add_mode_cpu_load=True) +tracker = EmissionsTracker(measure_power_secs=10, force_mode_cpu_load=True) try: tracker.start() with multiprocessing.Pool() as pool: From e30ba69a5a81e83df149a5fecfb8a28c14106665 Mon Sep 17 00:00:00 2001 From: benoit-cty <4-benoit-cty@users.noreply.git.leximpact.dev> Date: Tue, 7 Jan 2025 10:08:43 +0100 Subject: [PATCH 12/90] Add CPU load --- codecarbon/core/resource_tracker.py | 47 +++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/codecarbon/core/resource_tracker.py b/codecarbon/core/resource_tracker.py index 66ac6a144..921a55bce 100644 --- a/codecarbon/core/resource_tracker.py +++ b/codecarbon/core/resource_tracker.py @@ -89,17 +89,46 @@ def set_CPU_tracking(self): logger.info(f"CPU Model on constant consumption mode: {model}") self.tracker._conf["cpu_model"] = model if tdp: - hardware = CPU.from_utils( - self.tracker._output_dir, "constant", model, power - ) + if cpu.is_psutil_available(): + logger.warning( + "No CPU tracking mode found. Falling back on CPU load mode." + ) + hardware = CPU.from_utils( + self.tracker._output_dir, + MODE_CPU_LOAD, + model, + power, + tracking_mode=self.tracker._tracking_mode, + ) + self.cpu_tracker = "load" + else: + logger.warning( + "No CPU tracking mode found. Falling back on CPU constant mode." + ) + hardware = CPU.from_utils( + self.tracker._output_dir, "constant", model, power + ) + self.cpu_tracker = "global constant" self.tracker._hardware.append(hardware) else: - logger.warning( - "Failed to match CPU TDP constant. " - + "Falling back on a global constant." - ) - self.cpu_tracker = "global constant" - hardware = CPU.from_utils(self.tracker._output_dir, "constant") + if cpu.is_psutil_available(): + logger.warning( + "Failed to match CPU TDP constant. Falling back on CPU load mode." + ) + hardware = CPU.from_utils( + self.tracker._output_dir, + MODE_CPU_LOAD, + model, + power, + tracking_mode=self.tracker._tracking_mode, + ) + self.cpu_tracker = "load" + else: + logger.warning( + "Failed to match CPU TDP constant. Falling back on a global constant." + ) + self.cpu_tracker = "global constant" + hardware = CPU.from_utils(self.tracker._output_dir, "constant") self.tracker._hardware.append(hardware) def set_GPU_tracking(self): From cc1e6610fa14c2238d7f44d2ca36d8c53d96bcd7 Mon Sep 17 00:00:00 2001 From: benoit-cty <4-benoit-cty@users.noreply.git.leximpact.dev> Date: Tue, 7 Jan 2025 20:34:30 +0100 Subject: [PATCH 13/90] Add division to Power --- codecarbon/core/units.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/codecarbon/core/units.py b/codecarbon/core/units.py index 1665fe487..ab8886246 100644 --- a/codecarbon/core/units.py +++ b/codecarbon/core/units.py @@ -145,3 +145,9 @@ def __add__(self, other: "Power") -> "Power": def __mul__(self, factor: float) -> "Power": return Power(self.kW * factor) + + def __truediv__(self, divisor: float) -> "Power": + return Power(self.kW / divisor) + + def __floordiv__(self, divisor: float) -> "Power": + return Power(self.kW // divisor) From 9f73cbffe5d4ef681742ecf0d9c9e139f440c366 Mon Sep 17 00:00:00 2001 From: benoit-cty <4-benoit-cty@users.noreply.git.leximpact.dev> Date: Tue, 7 Jan 2025 20:35:55 +0100 Subject: [PATCH 14/90] Handle Pandas warning --- codecarbon/output_methods/file.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/codecarbon/output_methods/file.py b/codecarbon/output_methods/file.py index 448a292bc..a70778df3 100644 --- a/codecarbon/output_methods/file.py +++ b/codecarbon/output_methods/file.py @@ -78,12 +78,10 @@ def task_out(self, data: List[TaskEmissionsData], experiment_name: str): self.output_dir, "emissions_" + experiment_name + "_" + run_id + ".csv" ) df = pd.DataFrame(columns=data[0].values.keys()) - df = pd.concat( - [ - df, - pd.DataFrame.from_records( - [dict(data_point.values) for data_point in data] - ), - ] + new_df = pd.DataFrame.from_records( + [dict(data_point.values) for data_point in data] ) + # Filter out empty or all-NA columns, to avoid warnings from Pandas + new_df = new_df.dropna(axis=1, how="all") + df = pd.concat([df, new_df], ignore_index=True) df.to_csv(save_task_file_path, index=False) From 6a9509dd4efb147100b6691ff92eb6082a402515 Mon Sep 17 00:00:00 2001 From: benoit-cty <4-benoit-cty@users.noreply.git.leximpact.dev> Date: Tue, 7 Jan 2025 20:39:35 +0100 Subject: [PATCH 15/90] CPU Load task support --- codecarbon/core/resource_tracker.py | 37 ++++++++++++++++++++++++---- codecarbon/emissions_tracker.py | 28 +++++++++++++++++++++ codecarbon/external/hardware.py | 38 ++++++++++++++++++++++------- 3 files changed, 89 insertions(+), 14 deletions(-) diff --git a/codecarbon/core/resource_tracker.py b/codecarbon/core/resource_tracker.py index 921a55bce..c0de737ac 100644 --- a/codecarbon/core/resource_tracker.py +++ b/codecarbon/core/resource_tracker.py @@ -23,6 +23,26 @@ def set_RAM_tracking(self): def set_CPU_tracking(self): logger.info("[setup] CPU Tracking...") + if self.tracker._conf.get("force_mode_cpu_load", False): + if cpu.is_psutil_available(): + # Register a CPU with MODE_CPU_LOAD + tdp = cpu.TDP() + power = tdp.tdp + model = tdp.model + hardware = CPU.from_utils( + self.tracker._output_dir, + MODE_CPU_LOAD, + model, + power, + tracking_mode=self.tracker._tracking_mode, + ) + self.cpu_tracker = "load" + self.tracker._hardware.append(hardware) + return + else: + logger.warning( + "Force CPU load mode requested but psutil is not available." + ) if cpu.is_powergadget_available() and self.tracker._default_cpu_power is None: logger.info("Tracking Intel CPU via Power Gadget") self.cpu_tracker = "Power Gadget" @@ -32,9 +52,15 @@ def set_CPU_tracking(self): elif cpu.is_rapl_available(): logger.info("Tracking Intel CPU via RAPL interface") self.cpu_tracker = "RAPL" - hardware = CPU.from_utils(self.tracker._output_dir, "intel_rapl") + hardware = CPU.from_utils( + output_dir=self.tracker._output_dir, mode="intel_rapl" + ) self.tracker._hardware.append(hardware) self.tracker._conf["cpu_model"] = hardware.get_model() + if "AMD Ryzen Threadripper" in self.tracker._conf["cpu_model"]: + logger.warning( + "The RAPL energy and power reported is divided by 2 for all 'AMD Ryzen Threadripper' as it seems to give better results." + ) # change code to check if powermetrics needs to be installed or just sudo setup elif ( powermetrics.is_powermetrics_available() @@ -159,17 +185,18 @@ def set_GPU_tracking(self): self.tracker._conf["gpu_count"] = len( gpu_devices.devices.get_gpu_static_info() ) + self.gpu_tracker = "pynvml" else: logger.info("No GPU found.") - def set_CPU_GPU_ram_tracking(self, tracker): + def set_CPU_GPU_ram_tracking(self): """ Set up CPU, GPU and RAM tracking based on the user's configuration. param tracker: BaseEmissionsTracker object """ - self.set_RAM_tracking(tracker) - self.set_CPU_tracking(tracker) - self.set_GPU_tracking(tracker) + self.set_RAM_tracking() + self.set_CPU_tracking() + self.set_GPU_tracking() logger.debug( f"""The below tracking methods have been set up: diff --git a/codecarbon/emissions_tracker.py b/codecarbon/emissions_tracker.py index 988760433..a427eb069 100644 --- a/codecarbon/emissions_tracker.py +++ b/codecarbon/emissions_tracker.py @@ -64,6 +64,9 @@ class BaseEmissionsTracker(ABC): and `CarbonTracker.` """ + _scheduler: Optional[PeriodicScheduler] = None + _scheduler_monitor_power: Optional[PeriodicScheduler] = None + def _set_from_conf( self, var, name, default=None, return_type=None, prevent_setter=False ): @@ -333,6 +336,10 @@ def __init__( function=self._measure_power_and_energy, interval=self._measure_power_secs, ) + self._scheduler_monitor_power = PeriodicScheduler( + function=self._monitor_power, + interval=1, + ) self._data_source = DataSource() @@ -425,6 +432,7 @@ def start(self) -> None: hardware.start() self._scheduler.start() + self._scheduler_monitor_power.start() def start_task(self, task_name=None) -> None: """ @@ -451,6 +459,9 @@ def start_task(self, task_name=None) -> None: if self._scheduler: self._scheduler.stop() + # Task background thread for measuring power + self._scheduler_monitor_power.start() + if self._active_task: logger.info("A task is already under measure") return @@ -480,6 +491,9 @@ def stop_task(self, task_name: str = None) -> EmissionsData: emissions. :return: EmissionData for an execution task """ + if self._scheduler_monitor_power: + self._scheduler_monitor_power.stop() + task_name = task_name if task_name else self._active_task self._measure_power_and_energy() @@ -548,6 +562,9 @@ def stop(self) -> Optional[float]: if self._scheduler: self._scheduler.stop() self._scheduler = None + if self._scheduler_monitor_power: + self._scheduler_monitor_power.stop() + self._scheduler_monitor_power = None else: logger.warning("Tracker already stopped !") for task_name in self._tasks: @@ -676,6 +693,17 @@ def _get_cloud_metadata(self) -> CloudMetadata: :return: Metadata containing cloud info """ + def _monitor_power(self) -> None: + """ + Monitor the power consumption of the hardware. + We do this for hardware that does not support energy monitoring. + So we could average the power consumption. + This method is called every 1 second. Even if we are in Task mode. + """ + for hardware in self._hardware: + if isinstance(hardware, CPU): + hardware.monitor_power() + def _do_measurements(self) -> None: for hardware in self._hardware: h_time = time.perf_counter() diff --git a/codecarbon/external/hardware.py b/codecarbon/external/hardware.py index d0dee476d..ee9deb05a 100644 --- a/codecarbon/external/hardware.py +++ b/codecarbon/external/hardware.py @@ -152,6 +152,7 @@ def __init__( tracking_mode: str = "machine", ): assert tracking_mode in ["machine", "process"] + self._power_history: List[Power] = [] self._output_dir = output_dir self._mode = mode self._model = model @@ -183,18 +184,25 @@ def _get_power_from_cpu_load(self): When in MODE_CPU_LOAD """ if self._tracking_mode == "machine": - cpu_load = psutil.cpu_percent(interval=None) - # We add a minimum of 10% of TDP - power = max(self._tdp * 0.1, self._tdp * cpu_load / 100) + tdp = self._tdp + cpu_load = psutil.cpu_percent( + interval=0.5, percpu=False + ) # Convert to 0-1 range + logger.debug(f"CPU load : {self._tdp=} W and {cpu_load:.1f} %") + # Cubic relationship with minimum 10% of TDP + load_factor = 0.1 + 0.9 * ((cpu_load / 100.0) ** 3) + power = tdp * load_factor logger.debug( - f"CPU load {self._tdp} W x {cpu_load}% = {power} for whole machine." + f"CPU load {self._tdp} W and {cpu_load:.1f}% {load_factor=} => estimation of {power} W for whole machine." ) elif self._tracking_mode == "process": - cpu_load = self._process.cpu_percent(interval=None) / self._cpu_count + cpu_load = ( + self._process.cpu_percent(interval=0.5, percpu=False) / self._cpu_count + ) power = self._tdp * cpu_load / 100 logger.debug( - f"CPU load {self._tdp} W x {cpu_load}% = {power} for process {self._pid}." + f"CPU load {self._tdp} W and {cpu_load * 100:.1f}% => estimation of {power} W for process {self._pid}." ) else: raise Exception(f"Unknown tracking_mode {self._tracking_mode}") @@ -242,15 +250,23 @@ def _get_energy_from_cpus(self, delay: Time) -> Energy: return Energy.from_energy(energy) def total_power(self) -> Power: - cpu_power = self._get_power_from_cpus() - return cpu_power + self._power_history.append(self._get_power_from_cpus()) + power_history_in_W = [power.W for power in self._power_history] + cpu_power = sum(power_history_in_W) / len(power_history_in_W) + self._power_history = [] + return Power.from_watts(cpu_power) def measure_power_and_energy(self, last_duration: float) -> Tuple[Power, Energy]: if self._mode == "intel_rapl": energy = self._get_energy_from_cpus(delay=Time(seconds=last_duration)) power = self.total_power() + # Patch AMD Threadripper that count 2x the power + if "AMD Ryzen Threadripper" in self._model: + power = power / 2 + energy = energy / 2 return power, energy - # If not intel_rapl + # If not intel_rapl, we call the parent method from BaseHardware + # to compute energy from power and time return super().measure_power_and_energy(last_duration=last_duration) def start(self): @@ -260,6 +276,10 @@ def start(self): # The first time this is called it will return a meaningless 0.0 value which you are supposed to ignore. _ = self._get_power_from_cpu_load() + def monitor_power(self): + cpu_power = self._get_power_from_cpus() + self._power_history.append(cpu_power) + def get_model(self): return self._model From 8172f7324958229af5a82392ace0bd9a60c810d3 Mon Sep 17 00:00:00 2001 From: benoit-cty <4-benoit-cty@users.noreply.git.leximpact.dev> Date: Tue, 7 Jan 2025 20:40:02 +0100 Subject: [PATCH 16/90] CPU load and RAPL comparison CPU load and RAPL comparison --- examples/compare_cpu_load_and_RAPL.py | 293 ++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 examples/compare_cpu_load_and_RAPL.py diff --git a/examples/compare_cpu_load_and_RAPL.py b/examples/compare_cpu_load_and_RAPL.py new file mode 100644 index 000000000..73b760796 --- /dev/null +++ b/examples/compare_cpu_load_and_RAPL.py @@ -0,0 +1,293 @@ +""" +This script run a compute intensive task in parallel using multiprocessing.Pool +and compare the emissions measured by codecarbon using CPU load and RAPL mode. + +It runs in less than 2 minutes on a powerful machine with 32 cores. + +To run this script: +hatch run pip install tapo +export TAPO_USERNAME=XXX +export TAPO_PASSWORD=XXX +export IP_ADDRESS=192.168.0.XXX +hatch run python examples/compare_cpu_load_and_RAPL.py + +""" + +import asyncio +import os +import subprocess +import threading +import time +from threading import Thread + +import pandas as pd +import psutil +from tapo import ApiClient + +from codecarbon import EmissionsTracker +from codecarbon.external.hardware import CPU + +measure_power_secs = 10 +test_phase_duration = 60 +test_phase_number = 10 +measurements = [] +task_name = "" +cpu_name = "" +log_level = "INFO" + +# Read the credentials from the environment +tapo_username = os.getenv("TAPO_USERNAME") +tapo_password = os.getenv("TAPO_PASSWORD") +tapo_ip_address = os.getenv("IP_ADDRESS") + +tapo_last_energy = 0 +tapo_last_measurement = time.time() +tapo_client = None +if tapo_username: + tapo_client = ApiClient(tapo_username, tapo_password) +else: + print("WARNING : No tapo credentials found in the environment !!!") + + +async def read_tapo(): + global tapo_last_energy, tapo_last_measurement + if not tapo_client: + return 0, 0, 0 + try: + + device = await tapo_client.p110(tapo_ip_address) + + # device_info = await device.get_device_info() + # print(f"Device info: {device_info.to_dict()}") + + device_usage = await device.get_device_usage() + # print(f"Device usage: {device_usage.to_dict()}") + tapo_energy = device_usage.power_usage.today + # print(f"Energy: {tapo_energy} kWh") + time_delta = time.time() - tapo_last_measurement + tapo_last_measurement = time.time() + delta_energy = tapo_energy - tapo_last_energy + # print(f"Delta energy: {delta_energy} kWh") + power = await device.get_current_power() + + # print(f"Current power: {power.to_dict()}") + power = power.current_power + # print(f"Power: {power} W") + tapo_last_energy = tapo_energy + return power, delta_energy, time_delta + except Exception as e: + print(f"Error reading tapo: {e}") + return None, None, None + + +asyncio.run(read_tapo()) + + +class MeasurementPoint: + def __init__(self): + self.task_name = "" + self.cpu_name = "" + self.timestamp = 0 + self.cores_used = 0 + self.cpu_load = 0 + self.temperature = 0 + self.cpu_freq = 0 + self.rapl_power = 0 + self.estimated_power = 0 + self.tapo_power = 0 + self.tapo_energy = 0 + self.tapo_time_delta = 0 + + def __repr__(self): + return ( + f"Cores: {self.cores_used}, Load: {self.cpu_load:.1f}%, " + f"Temp: {self.temperature:.1f}°C, Freq: {self.cpu_freq:.1f}MHz, " + f"RAPL: {self.rapl_power:.1f}W, Est: {self.estimated_power:.1f}W" + f"Tapo: {self.tapo_power:.1f}W, {self.tapo_energy:.1f}kWh, {self.tapo_time_delta:.1f}s" + ) + + def to_dict(self): + return { + "task_name": self.task_name, + "cpu_name": cpu_name, + "timestamp": self.timestamp, + "cores_used": self.cores_used, + "cpu_load": self.cpu_load, + "temperature": self.temperature, + "cpu_freq": self.cpu_freq, + "rapl_power": self.rapl_power, + "estimated_power": self.estimated_power, + "tapo_power": self.tapo_power, + "tapo_energy": self.tapo_energy, + "tapo_time_delta": self.tapo_time_delta, + } + + +def collect_measurements(core_count): + point = MeasurementPoint() + point.task_name = task_name + point.timestamp = time.time() + point.cores_used = core_count + point.cpu_load = psutil.cpu_percent(interval=0.1) + + # Get CPU temperature (average across cores) + temps = psutil.sensors_temperatures() + # print(f"Temps: {temps}") + if "coretemp" in temps: + point.temperature = sum(t.current for t in temps["coretemp"]) / len( + temps["coretemp"] + ) + # 'asus_wmi_sensors': [shwtemp(label='CPU Temperature', current=48.0 + if "asus_wmi_sensors" in temps: + point.temperature = temps["asus_wmi_sensors"][0].current + + # Get CPU frequency (average across cores) + freqs = psutil.cpu_freq(percpu=True) + if freqs: + point.cpu_freq = sum(f.current for f in freqs) / len(freqs) + + point.rapl_power = tracker_rapl._cpu_power.W + point.estimated_power = tracker_cpu_load._cpu_power.W + # Read tapo + point.tapo_power, point.tapo_energy, point.tapo_time_delta = asyncio.run( + read_tapo() + ) + measurements.append(point) + + +def stress_ng(number_of_threads, test_phase_duration): + """ + Call 'stress-ng --matrix --rapl -t 1m --verify' + """ + subprocess.run( + f"stress-ng --matrix {number_of_threads} --rapl -t {test_phase_duration} --verify", + shell=True, + ) + + +def measurement_thread(core_count, stop_event): + while not stop_event.is_set(): + collect_measurements(core_count) + time.sleep(measure_power_secs / 2) + + +# Get the number of cores +cores = psutil.cpu_count() +cores_to_test = [i * (cores // test_phase_number) for i in range(test_phase_number + 1)] +cores_to_test.append(cores) +print("=" * 80) +print(f"We will run {len(cores_to_test)} tests for {test_phase_duration} seconds each.") +# print(f"Number of cores: {cores}, cores to test: {cores_to_test}") +print("=" * 80) +tracker_cpu_load = EmissionsTracker( + measure_power_secs=measure_power_secs, + force_mode_cpu_load=True, + allow_multiple_runs=True, + logger_preamble="CPU Load", + log_level=log_level, +) +tracker_rapl = EmissionsTracker( + measure_power_secs=measure_power_secs, + allow_multiple_runs=True, + logger_preamble="RAPL", + log_level=log_level, +) + +# Check if we could use RAPL +# print(f"Hardware: {tracker_rapl._hardware}") +for h in tracker_rapl._hardware: + # print(f"{h=}") + if isinstance(h, CPU): + # print(f"{h._mode=}") + # print(h._tracking_mode) # machine / process + if h._mode == "intel_rapl": + cpu_name = h.get_model() + break +else: + raise ValueError("No RAPL mode found") + +try: + for core_to_run in cores_to_test: + task_name = f"Stress-ng on {core_to_run} cores" + tracker_cpu_load.start_task(task_name + " CPU Load") + tracker_rapl.start_task(task_name + " RAPL") + + # Create and start measurement thread + stop_measurement = threading.Event() + measure_thread = Thread( + target=measurement_thread, args=(core_to_run, stop_measurement) + ) + measure_thread.start() + + # Run stress test + if core_to_run == 0: + # Just sleep, because, sending 0 to stress-ng mean "all cores" ! + time.sleep(test_phase_duration) + else: + stress_ng(core_to_run, test_phase_duration) + + # Stop measurement thread + stop_measurement.set() + measure_thread.join() + + cpu_load_data = tracker_cpu_load.stop_task() + rapl_data = tracker_rapl.stop_task() + print("=" * 80) + print(measurements[-1].__dict__) + print("=" * 80) + +finally: + # Stop measurement thread + stop_measurement.set() + measure_thread.join() + + +# Convert measurements to DataFrame +df = pd.DataFrame([m.to_dict() for m in measurements]) +date = time.strftime("%Y-%m-%d") +df.to_csv( + f"compare_cpu_load_and_RAPL-{cpu_name.replace(' ', '_')}-{date}.csv", index=False +) + +# Calculate correlation between variables +print("\nCorrelations with RAPL power:") +correlations = df[["cpu_load", "temperature", "cpu_freq", "cores_used"]].corrwith( + df["rapl_power"] +) +print(correlations) + +# Compare estimated vs actual power +print("\nMean Absolute Error:") +mae = (df["estimated_power"] - df["rapl_power"]).abs().mean() +print(f"{mae:.2f} watts") + +print("=" * 80) +for task_name, task in tracker_cpu_load._tasks.items(): + print( + f"Emissions : {1000 * task.emissions_data.emissions:.0f} g CO₂ for task {task_name}" + ) + print( + f"\tEnergy : {1000 * task.emissions_data.cpu_energy:.1f} Wh {1000 * task.emissions_data.gpu_energy:.1f} Wh RAM:{1000 * task.emissions_data.ram_energy}Wh" + ) + print( + f"\tPower CPU:{task.emissions_data.cpu_power:.0f}W GPU:{task.emissions_data.gpu_power:.0f}W RAM:{task.emissions_data.ram_power:.0f}W" + + f" during {task.emissions_data.duration:.1f} seconds." + ) +print("") +for task_name, task in tracker_rapl._tasks.items(): + print( + f"Emissions : {1000 * task.emissions_data.emissions:.0f} g CO₂ for task {task_name}" + ) + print( + f"\tEnergy : {1000 * task.emissions_data.cpu_energy:.1f} Wh {1000 * task.emissions_data.gpu_energy:.1f} Wh RAM{1000 * task.emissions_data.ram_energy:.1f}Wh" + ) + print( + f"\tPower CPU:{task.emissions_data.cpu_power:.0f}W GPU:{task.emissions_data.gpu_power:.0f}W RAM:{task.emissions_data.ram_power:.0f}W" + + f" during {task.emissions_data.duration:.1f} seconds." + ) +print("=" * 80) +""" +Lowest power at the plug when idle: 100 W +Peak power at the plug: 309 W +AMD Ryzen Threadripper 1950X 16-Core/32 threads Processor TDP: 180W +""" From 69384f34b228480f28942abf0ed804b7b79d002d Mon Sep 17 00:00:00 2001 From: benoit-cty <4-benoit-cty@users.noreply.git.leximpact.dev> Date: Wed, 8 Jan 2025 12:43:52 +0100 Subject: [PATCH 17/90] Better cpu load estimation Better cpu load estimation --- codecarbon/external/hardware.py | 34 ++- examples/compare_cpu_load_and_RAPL.ipynb | 294 +++++++++++++++++++++++ examples/compare_cpu_load_and_RAPL.py | 91 ++++--- 3 files changed, 374 insertions(+), 45 deletions(-) create mode 100644 examples/compare_cpu_load_and_RAPL.ipynb diff --git a/codecarbon/external/hardware.py b/codecarbon/external/hardware.py index ee9deb05a..3670dddcb 100644 --- a/codecarbon/external/hardware.py +++ b/codecarbon/external/hardware.py @@ -2,6 +2,7 @@ Encapsulates external dependencies to retrieve hardware metadata """ +import math import re import subprocess from abc import ABC, abstractmethod @@ -179,30 +180,41 @@ def __repr__(self) -> str: return s + ")" + @staticmethod + def _calculate_power_from_cpu_load(tdp, cpu_load): + load = cpu_load / 100.0 + + if load < 0.1: # Below 10% CPU load + return tdp * (0.05 * load * 10) + elif load <= 0.3: # 10-30% load - linear phase + return tdp * (0.05 + 1.8 * (load - 0.1)) + elif load <= 0.5: # 30-50% load - adjusted coefficients + # Increased base power and adjusted curve + base_power = 0.45 # Increased from 0.41 + power_range = 0.50 # Increased from 0.44 + factor = ((load - 0.3) / 0.2) ** 1.8 # Reduced power from 2.0 to 1.8 + return tdp * (base_power + power_range * factor) + else: # Above 50% - plateau phase + return tdp * (0.85 + 0.15 * (1 - math.exp(-(load - 0.5) * 5))) + def _get_power_from_cpu_load(self): """ When in MODE_CPU_LOAD """ if self._tracking_mode == "machine": tdp = self._tdp - cpu_load = psutil.cpu_percent( - interval=0.5, percpu=False - ) # Convert to 0-1 range - logger.debug(f"CPU load : {self._tdp=} W and {cpu_load:.1f} %") - # Cubic relationship with minimum 10% of TDP - load_factor = 0.1 + 0.9 * ((cpu_load / 100.0) ** 3) - power = tdp * load_factor + cpu_load = psutil.cpu_percent(interval=0.5, percpu=False) + power = self._calculate_power_from_cpu_load(tdp, cpu_load) logger.debug( - f"CPU load {self._tdp} W and {cpu_load:.1f}% {load_factor=} => estimation of {power} W for whole machine." + f"A TDP of {self._tdp} W and a CPU load of {cpu_load:.1f}% give an estimation of {power} W for whole machine." ) elif self._tracking_mode == "process": - cpu_load = ( self._process.cpu_percent(interval=0.5, percpu=False) / self._cpu_count ) - power = self._tdp * cpu_load / 100 + power = self._calculate_power_from_cpu_load(self.tdp, cpu_load) logger.debug( - f"CPU load {self._tdp} W and {cpu_load * 100:.1f}% => estimation of {power} W for process {self._pid}." + f"A TDP of {self._tdp} W and a CPU load of {cpu_load * 100:.1f}% give an estimation of {power} W for process {self._pid}." ) else: raise Exception(f"Unknown tracking_mode {self._tracking_mode}") diff --git a/examples/compare_cpu_load_and_RAPL.ipynb b/examples/compare_cpu_load_and_RAPL.ipynb new file mode 100644 index 000000000..04343da99 --- /dev/null +++ b/examples/compare_cpu_load_and_RAPL.ipynb @@ -0,0 +1,294 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "# !pip install matplotlib" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
cores_usedcpu_loadtemperaturecpu_freqrapl_powerestimated_powertapo_powertapo_energy
005.937.02176.2478440.6282252.6905571052
1311.649.02377.57887539.07547411.5843281502
2623.846.02633.80006284.69365044.1805572203
3932.553.02798.634781108.93354975.9896152224
41240.456.02961.187750145.914015101.6352272564
51548.954.03041.430656175.983861155.1952492575
61860.655.03188.987156168.973314158.9169452565
72168.356.03319.931125171.731616165.7241532624
82477.356.03331.505719173.132088169.9510612635
92789.456.03236.963219174.146872172.6142902574
103098.856.03318.317719174.895692174.2685122615
1132100.056.03417.113313175.624637174.8751422654
\n", + "
" + ], + "text/plain": [ + " cores_used cpu_load temperature cpu_freq rapl_power \\\n", + "0 0 5.9 37.0 2176.247844 0.628225 \n", + "1 3 11.6 49.0 2377.578875 39.075474 \n", + "2 6 23.8 46.0 2633.800062 84.693650 \n", + "3 9 32.5 53.0 2798.634781 108.933549 \n", + "4 12 40.4 56.0 2961.187750 145.914015 \n", + "5 15 48.9 54.0 3041.430656 175.983861 \n", + "6 18 60.6 55.0 3188.987156 168.973314 \n", + "7 21 68.3 56.0 3319.931125 171.731616 \n", + "8 24 77.3 56.0 3331.505719 173.132088 \n", + "9 27 89.4 56.0 3236.963219 174.146872 \n", + "10 30 98.8 56.0 3318.317719 174.895692 \n", + "11 32 100.0 56.0 3417.113313 175.624637 \n", + "\n", + " estimated_power tapo_power tapo_energy \n", + "0 2.690557 105 2 \n", + "1 11.584328 150 2 \n", + "2 44.180557 220 3 \n", + "3 75.989615 222 4 \n", + "4 101.635227 256 4 \n", + "5 155.195249 257 5 \n", + "6 158.916945 256 5 \n", + "7 165.724153 262 4 \n", + "8 169.951061 263 5 \n", + "9 172.614290 257 4 \n", + "10 174.268512 261 5 \n", + "11 174.875142 265 4 " + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df = pd.read_csv('../compare_cpu_load_and_RAPL-AMD_Ryzen_Threadripper_1950X_16-Core_Processor-2025-01-08.csv')\n", + "df[\"cores_used\tcpu_load\ttemperature\tcpu_freq\trapl_power\testimated_power\ttapo_power\ttapo_energy\".split(\"\\t\")]" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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lYcfflj5+++/s7V+1mWW93pkXV565swZpUePHoqnp6fi6OioNGnSRFm+fLnNtsxms/LBBx8o5cuXVxwcHJT69esry5cvV/r373/H5ZnXr19X+vbtq7i7uyseHh5K3759rZegPugy4yynTp2y1rl161ab1zIyMpQxY8YodevWVdzc3BQXFxelbt261st97yfrMuPdu3ffdzmDwaBMmjTJeuxDQkKU8ePH23yWWb7++msFUIYNG2Yzv127dgqgrF+//o51MjMzlWnTpik1a9ZUHBwcFC8vL6Vhw4bKpEmTlISEBOtyQK4up168eLHy2GOPKd7e3oqdnZ0SFBSk9OrVS9m4caPNctn57O91ufrtl+CePXtWeeGFF5RKlSopjo6Oire3t/Lwww8r69ats1kvNTVVGThwoOLh4aG4ubkpPXv2tF6yfbfLjK9evWqzfv/+/RUXF5c73nPr1q2VmjVr3lH3b7/9powfP17x9/dXnJyclE6dOinnz5+3WTc5OVl59tlnFU9PTwWw/k7f67LodevWKc2bN1ecnJwUd3d35YknnlCOHj1qs8y96s/6HYyIiLjjPYiSSaMo0uNICCGExcaNG3n44YdZtGgRPXr0ULscUYpJHxQhhBBCFDkSUIQQQghR5EhAEUIIIUSRI31QhBBCCFHkSAuKEEIIIYocCShCCCGEKHKK5UBtZrOZy5cv4+bmli83fhNCCCFEwVMUhaSkJMqUKfPAQQ2LZUC5fPnyHTcGE0IIIUTxEBkZSdmyZe+7TLEMKG5uboDlDWbdKl0IIYQQRVtiYiIhISHW7/H7KZYBJeu0jru7uwQUIYQQopjJTvcM6SQrhBBCiCJHAooQQgghihwJKEIIIYQociSgCCGEEKLIkYAihBBCiCJHAooQQgghihwJKEIIIYQociSgCCGEEKLIkYAihBBCiCJHAooQQgghihwJKEIIIYQociSgCCGEEKLIkYAihBBCCCuTWeHHrRGkZZpUrUMCihBCCCEASDeYGD53H+8tP8rIBf+hKIpqtdiptmchhBBCFBlxKZkM/nkPe87HYa/T0rlOGTQajWr1SEARQohSzmxWyDSZyTCYyTCZyDCYrc8tP003XzeaybzHMhlGs/WRaTSTYTSRabS87uVsT4i3MyFeTpTzdqacjzMBbo5otep9AYqbImNT6T9rF2evpuDuaMd3/RrR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2vv3MimWLSharZayZcuqXUap4e7uLv9BqEiOv7rk+KtLjr+6CuL4P6jlJIt0khVCCCFEkSMBRQghhBBFjgQUcU8ODg688847ODg4qF1KqSTHX11y/NUlx19dReH4F8tOskIIIYQo2aQFRQghhBBFjgQUIYQQQhQ5ElCEEEIIUeRIQBFCCCFEkSMBpZSbMmUKjRs3xs3NDX9/f7p27cqJEydslklPT2f48OH4+Pjg6upK9+7diY6OVqnikm3q1KloNBpGjhxpnSfHv2BdunSJ5557Dh8fH5ycnKhduzZ79uyxvq4oChMmTCAoKAgnJyfatWvHqVOnVKy45DCZTLz99tuEhobi5OREpUqVeO+992zu0yLHP/9s3ryZJ554gjJlyqDRaFi2bJnN69k51rGxsfTp0wd3d3c8PT0ZOHAgycnJBVKvBJRSbtOmTQwfPpwdO3awdu1aDAYDjz32GCkpKdZlXn31Vf766y8WLVrEpk2buHz5Mt26dVOx6pJp9+7dfPvtt9SpU8dmvhz/ghMXF0fz5s3R6/X8/fffHD16lI8//hgvLy/rMtOnT+eLL75g5syZ7Ny5ExcXF9q3b096erqKlZcM06ZNY8aMGXz11VccO3aMadOmMX36dL788kvrMnL8809KSgp169bl66+/vuvr2TnWffr04ciRI6xdu5bly5ezefNmhgwZUjAFK0LcIiYmRgGUTZs2KYqiKPHx8Yper1cWLVpkXebYsWMKoGzfvl2tMkucpKQkpUqVKsratWuV1q1bK6+88oqiKHL8C9q4ceOUFi1a3PN1s9msBAYGKh9++KF1Xnx8vOLg4KD89ttvhVFiidapUyflhRdesJnXrVs3pU+fPoqiyPEvSICydOlS6/PsHOujR48qgLJ7927rMn///bei0WiUS5cu5XuN0oIibCQkJADg7e0NwN69ezEYDLRr1866TPXq1SlXrhzbt29XpcaSaPjw4XTq1MnmOIMc/4L2559/0qhRI55++mn8/f2pX78+33//vfX1iIgIoqKibI6/h4cHTZs2leOfDx566CHWr1/PyZMnAThw4ABbt26lY8eOgBz/wpSdY719+3Y8PT1p1KiRdZl27dqh1WrZuXNnvtdULG8WKAqG2Wxm5MiRNG/enFq1agEQFRWFvb09np6eNssGBAQQFRWlQpUlz/z589m3bx+7d+++4zU5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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Plot the power in Y and the CPU load in X\n", + "df.plot(x='cpu_load', y=['tapo_power', 'rapl_power', 'estimated_power'], kind='line', title='CPU Load vs Power Consumption')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "codecarbon", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/examples/compare_cpu_load_and_RAPL.py b/examples/compare_cpu_load_and_RAPL.py index 73b760796..d79768d0d 100644 --- a/examples/compare_cpu_load_and_RAPL.py +++ b/examples/compare_cpu_load_and_RAPL.py @@ -16,7 +16,6 @@ import asyncio import os import subprocess -import threading import time from threading import Thread @@ -93,10 +92,13 @@ def __init__(self): self.temperature = 0 self.cpu_freq = 0 self.rapl_power = 0 + self.rapl_energy = 0 self.estimated_power = 0 + self.estimated_energy = 0 self.tapo_power = 0 self.tapo_energy = 0 self.tapo_time_delta = 0 + self.duration = 0 def __repr__(self): return ( @@ -116,14 +118,18 @@ def to_dict(self): "temperature": self.temperature, "cpu_freq": self.cpu_freq, "rapl_power": self.rapl_power, + "rapl_energy": self.rapl_energy, "estimated_power": self.estimated_power, + "estimated_energy": self.estimated_energy, "tapo_power": self.tapo_power, "tapo_energy": self.tapo_energy, "tapo_time_delta": self.tapo_time_delta, + "duration": self.duration, } def collect_measurements(core_count): + print(f"Collecting measurements for {core_count} cores") point = MeasurementPoint() point.task_name = task_name point.timestamp = time.time() @@ -146,8 +152,8 @@ def collect_measurements(core_count): if freqs: point.cpu_freq = sum(f.current for f in freqs) / len(freqs) - point.rapl_power = tracker_rapl._cpu_power.W - point.estimated_power = tracker_cpu_load._cpu_power.W + # point.rapl_power = tracker_rapl._cpu_power.W + # point.estimated_power = tracker_cpu_load._cpu_power.W # Read tapo point.tapo_power, point.tapo_energy, point.tapo_time_delta = asyncio.run( read_tapo() @@ -165,10 +171,10 @@ def stress_ng(number_of_threads, test_phase_duration): ) -def measurement_thread(core_count, stop_event): - while not stop_event.is_set(): - collect_measurements(core_count) - time.sleep(measure_power_secs / 2) +def measurement_thread(core_count): + # We do a mesurement in the middle of the task + time.sleep(test_phase_duration / 2) + collect_measurements(core_count) # Get the number of cores @@ -185,12 +191,14 @@ def measurement_thread(core_count, stop_event): allow_multiple_runs=True, logger_preamble="CPU Load", log_level=log_level, + save_to_file=False, ) tracker_rapl = EmissionsTracker( measure_power_secs=measure_power_secs, allow_multiple_runs=True, logger_preamble="RAPL", log_level=log_level, + save_to_file=False, ) # Check if we could use RAPL @@ -213,10 +221,7 @@ def measurement_thread(core_count, stop_event): tracker_rapl.start_task(task_name + " RAPL") # Create and start measurement thread - stop_measurement = threading.Event() - measure_thread = Thread( - target=measurement_thread, args=(core_to_run, stop_measurement) - ) + measure_thread = Thread(target=measurement_thread, args=(core_to_run,)) measure_thread.start() # Run stress test @@ -227,18 +232,23 @@ def measurement_thread(core_count, stop_event): stress_ng(core_to_run, test_phase_duration) # Stop measurement thread - stop_measurement.set() - measure_thread.join() + # measure_thread.join() cpu_load_data = tracker_cpu_load.stop_task() rapl_data = tracker_rapl.stop_task() + point = measurements[-1] + point.rapl_power = rapl_data.cpu_power + point.rapl_energy = rapl_data.cpu_energy + point.estimated_power = cpu_load_data.cpu_power + point.estimated_energy = cpu_load_data.cpu_energy + point.duration = rapl_data.duration + print("=" * 80) print(measurements[-1].__dict__) print("=" * 80) finally: # Stop measurement thread - stop_measurement.set() measure_thread.join() @@ -262,29 +272,42 @@ def measurement_thread(core_count, stop_event): print(f"{mae:.2f} watts") print("=" * 80) + +tasks = [] + for task_name, task in tracker_cpu_load._tasks.items(): - print( - f"Emissions : {1000 * task.emissions_data.emissions:.0f} g CO₂ for task {task_name}" - ) - print( - f"\tEnergy : {1000 * task.emissions_data.cpu_energy:.1f} Wh {1000 * task.emissions_data.gpu_energy:.1f} Wh RAM:{1000 * task.emissions_data.ram_energy}Wh" - ) - print( - f"\tPower CPU:{task.emissions_data.cpu_power:.0f}W GPU:{task.emissions_data.gpu_power:.0f}W RAM:{task.emissions_data.ram_power:.0f}W" - + f" during {task.emissions_data.duration:.1f} seconds." + tasks.append( + { + "task_name": task_name, + "emissions_cpu_load": task.emissions_data.emissions, + "cpu_energy_cpu_load": task.emissions_data.cpu_energy, + "gpu_energy_cpu_load": task.emissions_data.gpu_energy, + "ram_energy_cpu_load": task.emissions_data.ram_energy, + "cpu_power_cpu_load": task.emissions_data.cpu_power, + "gpu_power_cpu_load": task.emissions_data.gpu_power, + "ram_power_cpu_load": task.emissions_data.ram_power, + "duration_cpu_load": task.emissions_data.duration, + } ) print("") -for task_name, task in tracker_rapl._tasks.items(): - print( - f"Emissions : {1000 * task.emissions_data.emissions:.0f} g CO₂ for task {task_name}" - ) - print( - f"\tEnergy : {1000 * task.emissions_data.cpu_energy:.1f} Wh {1000 * task.emissions_data.gpu_energy:.1f} Wh RAM{1000 * task.emissions_data.ram_energy:.1f}Wh" - ) - print( - f"\tPower CPU:{task.emissions_data.cpu_power:.0f}W GPU:{task.emissions_data.gpu_power:.0f}W RAM:{task.emissions_data.ram_power:.0f}W" - + f" during {task.emissions_data.duration:.1f} seconds." - ) +task_id = 0 +for _, task in tracker_rapl._tasks.items(): + tasks[task_id]["emissions_rapl"] = task.emissions_data.emissions + tasks[task_id]["cpu_energy_rapl"] = task.emissions_data.cpu_energy + tasks[task_id]["gpu_energy_rapl"] = task.emissions_data.gpu_energy + tasks[task_id]["ram_energy_rapl"] = task.emissions_data.ram_energy + tasks[task_id]["cpu_power_rapl"] = task.emissions_data.cpu_power + tasks[task_id]["gpu_power_rapl"] = task.emissions_data.gpu_power + tasks[task_id]["ram_power_rapl"] = task.emissions_data.ram_power + tasks[task_id]["duration_rapl"] = task.emissions_data.duration + task_id += 1 +df_tasks = pd.DataFrame(tasks) +df_tasks.to_csv( + f"compare_cpu_load_and_RAPL-{cpu_name.replace(' ', '_')}-{date}-tasks.csv", + index=False, +) +print("=" * 80) +print(df_tasks) print("=" * 80) """ Lowest power at the plug when idle: 100 W From e3740a287aeeaf980792d6e307c7a206196c89c8 Mon Sep 17 00:00:00 2001 From: benoit-cty <4-benoit-cty@users.noreply.git.leximpact.dev> Date: Fri, 10 Jan 2025 09:08:53 +0100 Subject: [PATCH 18/90] change version --- docs/edit/conf.py | 2 +- pyproject.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/edit/conf.py b/docs/edit/conf.py index e38f05a5b..2e977799c 100644 --- a/docs/edit/conf.py +++ b/docs/edit/conf.py @@ -23,8 +23,8 @@ author = "BCG GAMMA, Comet.ml, Haverford College, MILA, Data For Good" # The full version, including alpha/beta/rc tags -release = "2.8.4" +release = "2.8.4" # -- General configuration --------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 0bedaea16..8ff4848cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -185,8 +185,8 @@ include = [ ] [tool.bumpver] -current_version = "3.0.0-rc0" -version_pattern = "MAJOR.MINOR.PATCH[-TAGNUM]" +current_version = "3.0.0_rc0" +version_pattern = "MAJOR.MINOR.PATCH[_TAGNUM]" [tool.bumpver.file_patterns] "codecarbon/_version.py" = [ From 185fa1acd4b02999e0eb804e8d9429cbfc312171 Mon Sep 17 00:00:00 2001 From: benoit-cty <4-benoit-cty@users.noreply.git.leximpact.dev> Date: Fri, 10 Jan 2025 10:45:24 +0100 Subject: [PATCH 19/90] Fix test test_carbon_tracker_offline_region_error Fix test_cpu_total_power debug test Fix test_cpu_total_power --- codecarbon/core/emissions.py | 4 +- codecarbon/emissions_tracker.py | 8 +- codecarbon/external/hardware.py | 13 ++- tests/test_cpu_load.py | 103 +++++++++++++++++++++++ tests/test_emissions_tracker_constant.py | 14 +-- tests/test_hardware.py | 63 -------------- 6 files changed, 130 insertions(+), 75 deletions(-) create mode 100644 tests/test_cpu_load.py delete mode 100644 tests/test_hardware.py diff --git a/codecarbon/core/emissions.py b/codecarbon/core/emissions.py index c98ce620c..9a070a003 100644 --- a/codecarbon/core/emissions.py +++ b/codecarbon/core/emissions.py @@ -90,7 +90,7 @@ def get_cloud_country_iso_code(self, cloud: CloudMetadata) -> str: selected = df.loc[flags] if not len(selected): raise ValueError( - "Unable to find country name for " + "Unable to find country ISO Code for " f"cloud_provider={cloud.provider}, " f"cloud_region={cloud.region}" ) @@ -105,7 +105,7 @@ def get_cloud_geo_region(self, cloud: CloudMetadata) -> str: selected = df.loc[flags] if not len(selected): raise ValueError( - "Unable to find country name for " + "Unable to find State/City name for " f"cloud_provider={cloud.provider}, " f"cloud_region={cloud.region}" ) diff --git a/codecarbon/emissions_tracker.py b/codecarbon/emissions_tracker.py index a427eb069..d59d0000d 100644 --- a/codecarbon/emissions_tracker.py +++ b/codecarbon/emissions_tracker.py @@ -771,7 +771,13 @@ def _measure_power_and_energy(self) -> None: every `self._measure_power_secs` seconds. :return: None """ - last_duration = time.perf_counter() - self._last_measured_time + try: + last_duration = time.perf_counter() - self._last_measured_time + except AttributeError as e: + logger.debug( + f"You need to start the tracker first before measuring. Or maybe you do multiple run at the same time ? Error: {e}" + ) + raise e warning_duration = self._measure_power_secs * 3 if last_duration > warning_duration: diff --git a/codecarbon/external/hardware.py b/codecarbon/external/hardware.py index 3670dddcb..77ce114ce 100644 --- a/codecarbon/external/hardware.py +++ b/codecarbon/external/hardware.py @@ -181,7 +181,14 @@ def __repr__(self) -> str: return s + ")" @staticmethod - def _calculate_power_from_cpu_load(tdp, cpu_load): + def _calculate_power_from_cpu_load(tdp, cpu_load, model): + if "AMD Ryzen Threadripper" in model: + return CPU._calculate_power_from_cpu_load_treadripper(tdp, cpu_load) + else: + return tdp * (cpu_load / 100.0) + + @staticmethod + def _calculate_power_from_cpu_load_treadripper(tdp, cpu_load): load = cpu_load / 100.0 if load < 0.1: # Below 10% CPU load @@ -204,7 +211,7 @@ def _get_power_from_cpu_load(self): if self._tracking_mode == "machine": tdp = self._tdp cpu_load = psutil.cpu_percent(interval=0.5, percpu=False) - power = self._calculate_power_from_cpu_load(tdp, cpu_load) + power = self._calculate_power_from_cpu_load(tdp, cpu_load, self._model) logger.debug( f"A TDP of {self._tdp} W and a CPU load of {cpu_load:.1f}% give an estimation of {power} W for whole machine." ) @@ -212,7 +219,7 @@ def _get_power_from_cpu_load(self): cpu_load = ( self._process.cpu_percent(interval=0.5, percpu=False) / self._cpu_count ) - power = self._calculate_power_from_cpu_load(self.tdp, cpu_load) + power = self._calculate_power_from_cpu_load(self.tdp, cpu_load, self._model) logger.debug( f"A TDP of {self._tdp} W and a CPU load of {cpu_load * 100:.1f}% give an estimation of {power} W for process {self._pid}." ) diff --git a/tests/test_cpu_load.py b/tests/test_cpu_load.py new file mode 100644 index 000000000..cd8132df4 --- /dev/null +++ b/tests/test_cpu_load.py @@ -0,0 +1,103 @@ +import unittest +from time import sleep +from unittest import mock + +from codecarbon.core.units import Power +from codecarbon.emissions_tracker import OfflineEmissionsTracker +from codecarbon.external.hardware import CPU, MODE_CPU_LOAD + + +@mock.patch("codecarbon.core.cpu.is_psutil_available", return_value=True) +@mock.patch("codecarbon.core.cpu.is_powergadget_available", return_value=False) +@mock.patch("codecarbon.core.cpu.is_rapl_available", return_value=False) +class TestCPULoad(unittest.TestCase): + @mock.patch( + "codecarbon.external.hardware.CPU._get_power_from_cpu_load", + return_value=Power.from_watts(50), + ) + def test_cpu_total_power( + self, + mocked_is_psutil_available, + mocked_is_powergadget_available, + mocked_is_rapl_available, + mocked_get_power_from_cpu_load, + ): + cpu = CPU.from_utils( + None, MODE_CPU_LOAD, "Intel(R) Core(TM) i7-7600U CPU @ 2.80GHz", 100 + ) + cpu.start() + sleep(0.5) + power = cpu._get_power_from_cpu_load() + self.assertEqual(power.W, 50) + self.assertEqual(cpu.total_power().W, 50) + + def test_cpu_load_detection( + self, + mocked_is_psutil_available, + mocked_is_powergadget_available, + mocked_is_rapl_available, + ): + tracker = OfflineEmissionsTracker(country_iso_code="FRA") + for hardware in tracker._hardware: + if isinstance(hardware, CPU) and hardware._mode == MODE_CPU_LOAD: + break + else: + raise Exception("No CPU load !!!") + tracker.start() + sleep(0.5) + emission = tracker.stop() + self.assertGreater(emission, 0.0) + + def test_cpu_calculate_power_from_cpu_load_threadripper( + self, + mocked_is_psutil_available, + mocked_is_powergadget_available, + mocked_is_rapl_available, + ): + tdp = 100 + cpu_model = "AMD Ryzen Threadripper 3990X 64-Core Processor" + cpu = CPU.from_utils(None, MODE_CPU_LOAD, cpu_model, tdp) + tests_values = [ + { + "cpu_load": 0.0, + "expected_power": 0.0, + }, + { + "cpu_load": 50, + "expected_power": 95.0, + }, + { + "cpu_load": 100, + "expected_power": 98.76872502064151, + }, + ] + for test in tests_values: + power = cpu._calculate_power_from_cpu_load(tdp, test["cpu_load"], cpu_model) + self.assertEqual(power, test["expected_power"]) + + def test_cpu_calculate_power_from_cpu_load_linear( + self, + mocked_is_psutil_available, + mocked_is_powergadget_available, + mocked_is_rapl_available, + ): + tdp = 100 + cpu_model = "Random Processor" + cpu = CPU.from_utils(None, MODE_CPU_LOAD, cpu_model, tdp) + tests_values = [ + { + "cpu_load": 0.0, + "expected_power": 0.0, + }, + { + "cpu_load": 50, + "expected_power": 50.0, + }, + { + "cpu_load": 100, + "expected_power": 100.0, + }, + ] + for test in tests_values: + power = cpu._calculate_power_from_cpu_load(tdp, test["cpu_load"], cpu_model) + self.assertEqual(power, test["expected_power"]) diff --git a/tests/test_emissions_tracker_constant.py b/tests/test_emissions_tracker_constant.py index 7fd4655c1..6e72cfe45 100644 --- a/tests/test_emissions_tracker_constant.py +++ b/tests/test_emissions_tracker_constant.py @@ -118,19 +118,21 @@ def test_carbon_tracker_offline_region_error(self): output_file=self.emissions_file, ) tracker.start() + tracker._measure_power_and_energy() + cloud: CloudMetadata = tracker._get_cloud_metadata() try: - with self.assertRaises(ValueError) as context: - tracker._measure_power_and_energy() - self.assertTrue("Unable to find country name" in context.exception.args[0]) - cloud: CloudMetadata = tracker._get_cloud_metadata() with self.assertRaises(ValueError) as context: tracker._emissions.get_cloud_country_iso_code(cloud) - self.assertTrue("Unable to find country name" in context.exception.args[0]) + self.assertTrue( + "Unable to find country ISO Code" in context.exception.args[0] + ) with self.assertRaises(ValueError) as context: tracker._emissions.get_cloud_geo_region(cloud) - self.assertTrue("Unable to find country name" in context.exception.args[0]) + self.assertTrue( + "Unable to find State/City name for " in context.exception.args[0] + ) with self.assertRaises(ValueError) as context: tracker._emissions.get_cloud_country_name(cloud) diff --git a/tests/test_hardware.py b/tests/test_hardware.py deleted file mode 100644 index d3dfcc791..000000000 --- a/tests/test_hardware.py +++ /dev/null @@ -1,63 +0,0 @@ -import unittest -from time import sleep -from unittest import mock - -from codecarbon.emissions_tracker import OfflineEmissionsTracker -from codecarbon.external.hardware import CPU, GPU, MODE_CPU_LOAD -from tests.testdata import TWO_GPU_DETAILS_RESPONSE - - -@mock.patch("codecarbon.core.cpu.is_psutil_available", return_value=True) -@mock.patch("codecarbon.core.cpu.is_powergadget_available", return_value=False) -@mock.patch("codecarbon.core.cpu.is_rapl_available", return_value=False) -class TestCPULoad(unittest.TestCase): - def test_cpu_total_power( - self, - mocked_is_psutil_available, - mocked_is_powergadget_available, - mocked_is_rapl_available, - ): - cpu = CPU.from_utils( - None, MODE_CPU_LOAD, "Intel(R) Core(TM) i7-7600U CPU @ 2.80GHz", 100 - ) - cpu.start() - sleep(0.5) - self.assertGreater(cpu.total_power().W, 1) - - def test_cpu_load_detection( - self, - mocked_is_psutil_available, - mocked_is_powergadget_available, - mocked_is_rapl_available, - ): - tracker = OfflineEmissionsTracker(country_iso_code="FRA") - for hardware in tracker._hardware: - if isinstance(hardware, CPU) and hardware._mode == MODE_CPU_LOAD: - break - else: - raise Exception("No CPU load !!!") - tracker.start() - sleep(0.5) - emission = tracker.stop() - self.assertGreater(emission, 0.0) - - -@mock.patch("codecarbon.core.gpu.is_gpu_details_available", return_value=True) -@mock.patch( - "codecarbon.external.hardware.get_gpu_details", - return_value=TWO_GPU_DETAILS_RESPONSE, -) -class TestGPUMetadata(unittest.TestCase): - def test_gpu_metadata_total_power( - self, mocked_get_gpu_details, mocked_is_gpu_details_available - ): - gpu = GPU.from_utils() - self.assertAlmostEqual(0.074318, gpu.total_power().kW, places=2) - - def test_gpu_metadata_one_gpu_power( - self, mocked_get_gpu_details, mocked_is_gpu_details_available - ): - gpu = GPU.from_utils() - self.assertAlmostEqual( - 0.032159, gpu._get_power_for_gpus(gpu_ids=[1]).kW, places=2 - ) From e8089f4c7b4270dd437e6e36fbcf21a382f39e7b Mon Sep 17 00:00:00 2001 From: benoit-cty <4-benoit-cty@users.noreply.git.leximpact.dev> Date: Fri, 10 Jan 2025 14:57:42 +0100 Subject: [PATCH 20/90] RAPL path --- codecarbon/core/resource_tracker.py | 2 +- docs/methodology.html | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/codecarbon/core/resource_tracker.py b/codecarbon/core/resource_tracker.py index c0de737ac..dab7a805e 100644 --- a/codecarbon/core/resource_tracker.py +++ b/codecarbon/core/resource_tracker.py @@ -98,7 +98,7 @@ def set_CPU_tracking(self): elif is_windows_os(): cpu_tracking_install_instructions = "Windows OS detected: Please install Intel Power Gadget to measure CPU" elif is_linux_os(): - cpu_tracking_install_instructions = "Linux OS detected: Please ensure RAPL files exist at \\sys\\class\\powercap\\intel-rapl to measure CPU" + cpu_tracking_install_instructions = "Linux OS detected: Please ensure RAPL files exist at /sys/class/powercap/intel-rapl to measure CPU" logger.warning( f"No CPU tracking mode found. Falling back on CPU constant mode. \n {cpu_tracking_install_instructions}\n" ) diff --git a/docs/methodology.html b/docs/methodology.html index 6c21dfb88..6405c212e 100644 --- a/docs/methodology.html +++ b/docs/methodology.html @@ -10,7 +10,7 @@ - + @@ -21,17 +21,17 @@ - + - +