diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index 9b3daf699..69c33fd54 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -1,5 +1,6 @@ import os import signal +import subprocess import sys import time from pathlib import Path @@ -339,6 +340,126 @@ def config(): ) +@codecarbon.command( + "run", + short_help="Run a command and track its emissions.", + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, +) +def run( + ctx: typer.Context, + log_level: Annotated[ + str, typer.Option(help="Log level (critical, error, warning, info, debug)") + ] = "error", +): + """ + Run a command and track its carbon emissions. + + This command wraps any executable and measures the process's total power + consumption during its execution. When the command completes, a summary + report is displayed and emissions data is saved to a CSV file. + + Note: This tracks process-level emissions (only the specific command), not the + entire machine. For machine-level tracking, use the `monitor` command. + + Examples: + + Do not use quotes around the command. Use -- to separate CodeCarbon args. + + # Run any shell command: + codecarbon run -- ./benchmark.sh + + # Commands with arguments (use single quotes for special chars): + codecarbon run -- python -c 'print("Hello World!")' + + # Pipe the command output: + codecarbon run -- npm run test > output.txt + + # Display the CodeCarbon detailed logs: + codecarbon run --log-level debug -- python --version + + The emissions data is appended to emissions.csv (default) in the current + directory. The file path is shown in the final report. + """ + # Suppress all CodeCarbon logs during execution + from codecarbon.external.logger import set_logger_level + + set_logger_level(log_level) + + # Get the command from remaining args + command = ctx.args + + if not command: + print( + "ERROR: No command provided. Use: codecarbon run -- ", + file=sys.stderr, + ) + raise typer.Exit(1) + + # Initialize tracker with specified logging level + tracker = EmissionsTracker( + log_level=log_level, save_to_logger=False, tracking_mode="process" + ) + + print("🌱 CodeCarbon: Starting emissions tracking...") + print(f" Command: {' '.join(command)}") + print() + + tracker.start() + + process = None + try: + # Run the command, streaming output to console + process = subprocess.Popen( + command, + stdout=sys.stdout, + stderr=sys.stderr, + text=True, + ) + + # Wait for completion + exit_code = process.wait() + + except FileNotFoundError: + print(f"āŒ Error: Command not found: {command[0]}", file=sys.stderr) + exit_code = 127 + except KeyboardInterrupt: + print("\nāš ļø Interrupted by user", file=sys.stderr) + if process is not None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + exit_code = 130 + except Exception as e: + print(f"āŒ Error running command: {e}", file=sys.stderr) + exit_code = 1 + finally: + emissions = tracker.stop() + print() + print("=" * 60) + print("🌱 CodeCarbon Emissions Report") + print("=" * 60) + print(f" Command: {' '.join(command)}") + if emissions is not None: + print(f" Emissions: {emissions * 1000:.4f} g CO2eq") + else: + print(" Emissions: N/A") + + # Show where the data was saved + if hasattr(tracker, "_conf") and "output_file" in tracker._conf: + output_path = tracker._conf["output_file"] + # Make it absolute if it's relative + if not os.path.isabs(output_path): + output_path = os.path.abspath(output_path) + print(f" Saved to: {output_path}") + + print(" āš ļø Note: Tracked the command process and its children") + print("=" * 60) + + raise typer.Exit(exit_code) + + @codecarbon.command("monitor", short_help="Monitor your machine's carbon emissions.") def monitor( measure_power_secs: Annotated[ diff --git a/codecarbon/external/hardware.py b/codecarbon/external/hardware.py index 30ce125ee..aaa548617 100644 --- a/codecarbon/external/hardware.py +++ b/codecarbon/external/hardware.py @@ -4,6 +4,7 @@ import math import re +import time from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Dict, Iterable, List, Optional, Tuple @@ -182,6 +183,9 @@ def __init__( self._pid = psutil.Process().pid self._cpu_count = count_cpus() self._process = psutil.Process(self._pid) + # For process tracking: store last measurement time and CPU times + self._last_measurement_time: Optional[float] = None + self._last_cpu_times: Dict[int, float] = {} # pid -> total cpu time if self._mode == "intel_power_gadget": self._intel_interface = IntelPowerGadget(self._output_dir) @@ -245,11 +249,62 @@ def _get_power_from_cpu_load(self): f"CPU load {self._tdp} W and {cpu_load:.1f}% {load_factor=} => estimation of {power} W for whole machine." ) elif self._tracking_mode == "process": + # Use CPU times for accurate process tracking + current_time = time.time() + current_cpu_times: Dict[int, float] = {} + + # Get CPU time for main process and all children + try: + processes = [self._process] + self._process.children(recursive=True) + except (psutil.NoSuchProcess, psutil.AccessDenied): + processes = [self._process] + + for proc in processes: + try: + cpu_times = proc.cpu_times() + # Total CPU time = user + system time + total_cpu_time = cpu_times.user + cpu_times.system + current_cpu_times[proc.pid] = total_cpu_time + except (psutil.NoSuchProcess, psutil.AccessDenied): + logger.debug( + f"Process {proc.pid} disappeared or access denied when getting CPU times." + ) + + # Calculate CPU usage based on delta + if self._last_measurement_time is not None: + time_delta = current_time - self._last_measurement_time + if time_delta > 0: + total_cpu_delta = 0.0 + for pid, cpu_time in current_cpu_times.items(): + last_cpu_time = self._last_cpu_times.get(pid, cpu_time) + cpu_delta = cpu_time - last_cpu_time + if cpu_delta > 0: + total_cpu_delta += cpu_delta + logger.debug( + f"Process {pid} CPU time delta: {cpu_delta:.3f}s" + ) + + # CPU load as percentage (can be > 100% with multiple cores) + # total_cpu_delta is the CPU time used, time_delta is wall clock time + cpu_load = (total_cpu_delta / time_delta) * 100 + logger.debug( + f"Total CPU delta: {total_cpu_delta:.3f}s over {time_delta:.3f}s = {cpu_load:.1f}% (across {self._cpu_count} cores)" + ) + else: + cpu_load = 0.0 + else: + cpu_load = 0.0 + logger.debug("First measurement, no CPU delta available yet") - cpu_load = self._process.cpu_percent(interval=0.5) / self._cpu_count - power = self._tdp * cpu_load / 100 + # Store for next measurement + self._last_measurement_time = current_time + self._last_cpu_times = current_cpu_times + + # Normalize to percentage of total CPU capacity + cpu_load_normalized = cpu_load / self._cpu_count + power = self._tdp * cpu_load_normalized / 100 logger.debug( - f"CPU load {self._tdp} W and {cpu_load * 100:.1f}% => estimation of {power} W for process {self._pid}." + f"CPU load {self._tdp} W and {cpu_load:.1f}% ({cpu_load_normalized:.1f}% normalized) => estimation of {power:.2f} W for process {self._pid} and {len(current_cpu_times) - 1} children." ) else: raise Exception(f"Unknown tracking_mode {self._tracking_mode}") @@ -318,9 +373,13 @@ 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() + # Reset process tracking state for fresh measurements + self._last_measurement_time = None + self._last_cpu_times = {} 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. _ = self._get_power_from_cpu_load() + _ = self._get_power_from_cpu_load() def monitor_power(self): cpu_power = self._get_power_from_cpus() diff --git a/docs/edit/usage.rst b/docs/edit/usage.rst index 169fa3d7d..378d17147 100644 --- a/docs/edit/usage.rst +++ b/docs/edit/usage.rst @@ -58,7 +58,67 @@ The command line could also works without internet by providing the country code codecarbon monitor --offline --country-iso-code FRA -Implementing CodeCarbon in your code allows you to track the emissions of a specific block of code. + +Running Any Command with CodeCarbon +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you want to track emissions while running any command or program (not just Python scripts), you can use the ``codecarbon run`` command. +This allows non-Python users to measure machine emissions during the execution of any command: + +.. code-block:: console + + codecarbon run -- + +Do not surround ```` with quotes. The double hyphen ``--`` indicates the end of CodeCarbon options and the beginning of the command to run. + +**Examples:** + +.. code-block:: console + + # Run a shell script + codecarbon run -- ./benchmark.sh + + # Run a command with arguments (use quotes for special characters) + codecarbon run -- bash -c 'echo "Processing..."; sleep 30; echo "Done!"' + + # Run Python scripts + codecarbon run -- python train_model.py + + # Run Node.js applications + codecarbon run -- node app.js + + # Run tests with output redirection + codecarbon run -- npm run test > output.txt + + # Display the CodeCarbon detailed logs + codecarbon run --log-level debug -- python --version + +**Output:** + +When the command completes, CodeCarbon displays a summary report and saves the emissions data to a CSV file: + +.. code-block:: console + + 🌱 CodeCarbon: Starting emissions tracking... + Command: bash -c echo "Processing..."; sleep 30; echo "Done!" + + Processing... + Done! + + ============================================================ + 🌱 CodeCarbon Emissions Report + ============================================================ + Command: bash -c echo "Processing..."; sleep 30; echo "Done!" + Emissions: 0.0317 g CO2eq + Saved to: /home/user/emissions.csv + āš ļø Note: Measured entire machine (includes all system processes) + ============================================================ + +.. note:: + The ``codecarbon run`` command tracks process-level emissions (only the specific command), not the + entire machine. For machine-level tracking, use the ``codecarbon monitor`` command. + +For more fine-grained tracking, implementing CodeCarbon in your code allows you to track the emissions of a specific block of code. Explicit Object ~~~~~~~~~~~~~~~ diff --git a/examples/command_line_tool.py b/examples/command_line_tool.py index da6f6584e..5308f3dfe 100644 --- a/examples/command_line_tool.py +++ b/examples/command_line_tool.py @@ -1,8 +1,16 @@ """ This example demonstrates how to use CodeCarbon with command line tools. -Here we measure the emissions of an speech-to-text with WhisperX. +āš ļø IMPORTANT LIMITATION: +CodeCarbon tracks emissions at the MACHINE level when monitoring external commands +via subprocess. It measures total system power during the command execution, which +includes the command itself AND all other system processes. +For accurate process-level tracking, the tracking code must be embedded in the +application being measured (not possible with external binaries like WhisperX). + +This example measures emissions during WhisperX execution, but cannot isolate +WhisperX's exact contribution from other system activity. """ import subprocess