diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..144e89b --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,55 @@ +name: docs + +on: + push: + branches: [main, polish-ecal-package] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install package with docs extras + run: pip install -e ".[docs]" + + - name: Build Sphinx docs + run: sphinx-build -b html -W --keep-going docs docs/_build/html + + - name: Assemble combined site (website at /, docs at /docs/) + run: | + rm -rf site + mkdir -p site + cp -r website/. site/ + mkdir -p site/docs + cp -r docs/_build/html/. site/docs/ + + - name: Upload site artifact + uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/polish-ecal-package')) + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..cf10ea0 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,57 @@ +name: publish + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Check release tag matches pyproject.toml version + run: | + TAG="${GITHUB_REF_NAME#v}" + PKG_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") + if [ "$TAG" != "$PKG_VERSION" ]; then + echo "Release tag '$GITHUB_REF_NAME' (version '$TAG') does not match pyproject.toml version '$PKG_VERSION'" + exit 1 + fi + + - name: Install build + run: pip install build + + - name: Build sdist and wheel + run: python -m build + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/ecal-energy/ + permissions: + id-token: write + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 5660c2b..5fe04d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ +# macOS +.DS_Store + +# Paper PDF (large binary) +paper.pdf + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -70,6 +76,10 @@ instance/ # Sphinx documentation docs/_build/ +docs/api/generated/ + +# Combined GitHub Pages assembly (website + docs) +/site/ # PyBuilder .pybuilder/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..811dabb --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,17 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +sphinx: + configuration: docs/conf.py + fail_on_warning: true + +python: + install: + - method: pip + path: . + extra_requirements: + - docs diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..f24890a --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +recursive-include tests *.py +recursive-include src/ecal/hardware/data *.yaml diff --git a/README.md b/README.md index 71469df..536233b 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,141 @@ # eCAL -Simulator for the eCAL metric +Analytical estimation of the energy cost of the AI lifecycle (J/bit). + +eCAL computes the total energy consumed across the full AI model lifecycle — data transmission, preprocessing, training, evaluation, and inference — using closed-form FLOP formulas and hardware power profiles. + +Published in **IEEE Journal on Selected Areas in Communications (JSAC), 2026**. + +[Documentation](https://sensorlab.github.io/eCAL/docs/) ## Installation -To install the required dependencies, run the following command: + +### From source (recommended for development) + +```bash +git clone https://github.com/sensorlab/eCAL.git +cd eCAL +pip install -e ".[dev]" +``` + +### From PyPI + +```bash +pip install ecal-energy +``` + +### Dependencies only (legacy) + ```bash pip install -r requirements.txt ``` -in case if you want to run LLMs you might need additional dependencies, you can install them by running the following command: +## Quickstart + +### Python API + +```python +import ecal + +result = ecal.estimate( + model_type="MLP", + model_params={"num_layers": 3, "din": 10, "dout": 2}, + num_samples=1000, + num_epochs=50, + hardware="apple_m2", +) + +print(f"Total energy: {result['total']:.4f} J") +print(f"eCAL: {result['ecal_j_per_bit']:.2e} J/bit") +``` + +### CLI + ```bash -pip install transformers sentencepiece tiktoken +# Estimate energy for an MLP +ecal estimate --model MLP --layers 3 --epochs 50 --hardware apple_m2 + +# JSON output +ecal estimate --model Transformer --layers 6 --hardware nvidia_h100_sxm --json + +# List available hardware profiles +ecal profiles + +# Version +ecal --version ``` -## Usage -To run the calculator, use the following command: + +### Legacy (RunCalculator.py) + ```bash +# Edit configs/CalculatorConfig.py, then: python RunCalculator.py ``` + +## Supported Models + +| Model | FLOP Calculator | Key Parameters | +|-------------|------------------------|-----------------------------------------------------| +| MLP | `MLPCalculator` | `num_layers`, `din`, `dout` | +| CNN | `CNNCalculator` | `num_cnv_layers`, `num_pool_layers`, `i_r`, `k_r` | +| KAN | `KANCalculator` | `num_layers`, `grid_size`, `din`, `dout` | +| Transformer | `TransformerCalculator`| `context_length`, `embedding_size`, `num_heads`, `num_decoder_blocks` | + +## Hardware Profiles + +| Profile | FP32 FLOPS | TDP (W) | Device | +|--------------------|-------------|---------|--------| +| `apple_m2` | 3.6 TFLOPS | 22 | mps | +| `nvidia_a100_80gb` | 19.5 TFLOPS | 400 | cuda | +| `nvidia_h100_sxm` | 67 TFLOPS | 700 | cuda | +| `generic_cpu` | 1 TFLOPS | 100 | cpu | +| `generic_edge` | 0.01 TFLOPS | 15 | cpu | + +## Architecture + +``` + ecal.estimate() + | + +--------+-------+-------+--------+ + | | | | | + Transmission Preproc Training Eval Inference + | | | | | + v v v v v + Protocol FLOP FLOP FLOP FLOP + Configs Calcs Calcs Calcs Calcs + (per model type) + | + Hardware Profile + (FLOPS, power, TDP) + | + Energy = time * power + | + eCAL = total_E / total_bits +``` + ## Configuration -The configuration is done in the `CalculatorConfig.py` file. What specific configuration options are available can be found in the file itself. -To change the Control and Data plane overheads of the transmission layer or implement new protocols you can change the values in the `configs/ProtocolConfigs.py` file. + +Protocol configs are in `configs/ProtocolConfigs.py` — supports 7 OSI layers with multiple protocol options (HTTP, TCP, IPv4, WiFi, Bluetooth, etc.). + +Calculator parameters are in `configs/CalculatorConfig.py` for the legacy `RunCalculator.py` interface. + +## Development + +```bash +pip install -e ".[dev]" +pytest # run tests +ruff check src/ tests/ # lint +mypy src/ecal/ # type check +``` ## Citation -If you use this tool please cite our [paper](https://ieeexplore.ieee.org/abstract/document/11298182): + +If you use this tool please cite our [paper](https://ieeexplore.ieee.org/abstract/document/11298182): ``` @ARTICLE{11298182, author={Chou, Shih-Kai and Hribar, Jernej and Hanžel, Vid and Mohorčič, Mihael and Fortuna, Carolina}, - journal={IEEE Journal on Selected Areas in Communications}, - title={The Energy Cost of Artificial Intelligence Lifecycle in Communication Networks}, + journal={IEEE Journal on Selected Areas in Communications}, + title={The Energy Cost of Artificial Intelligence Lifecycle in Communication Networks}, year={2026}, volume={44}, number={}, @@ -40,8 +148,8 @@ Other related work: ``` @INPROCEEDINGS{11349371, author={Chou, Shih-Kai and Hribar, Jernej and Bertalanič, Blaž and Mohorčič, Mihael and Lagkas, Thomas and Sarigiannidis, Panagiotis and Fortuna, Carolina}, - booktitle={2025 IEEE Conference on Network Function Virtualization and Software-Defined Networking (NFV-SDN)}, - title={Energy Cost of the AI/ML Workflow in O-RAN}, + booktitle={2025 IEEE Conference on Network Function Virtualization and Software-Defined Networking (NFV-SDN)}, + title={Energy Cost of the AI/ML Workflow in O-RAN}, year={2025}, volume={}, number={}, @@ -53,8 +161,8 @@ Other related work: ``` @INPROCEEDINGS{10849732, author={Chou, Shih-Kai and Hribar, Jernej and Mohorčič, Mihael and Fortuna, Carolina}, - booktitle={2024 IEEE Conference on Standards for Communications and Networking (CSCN)}, - title={Towards the Standardization of Energy Efficiency Metrics of the AI Lifecycle in 6G and Beyond}, + booktitle={2024 IEEE Conference on Standards for Communications and Networking (CSCN)}, + title={Towards the Standardization of Energy Efficiency Metrics of the AI Lifecycle in 6G and Beyond}, year={2024}, volume={}, number={}, @@ -62,3 +170,7 @@ Other related work: keywords={Measurement;6G mobile communication;Energy consumption;Costs;Energy measurement;Energy efficiency;Computational efficiency;Quality of experience;Artificial intelligence;Standards;6G;AI-native network;energy efficiency}, doi={10.1109/CSCN63874.2024.10849732}} ``` + +## License + +BSD 3-Clause License. See [LICENSE](LICENSE). diff --git a/RunCalculator.py b/RunCalculator.py index 091abf4..43df093 100644 --- a/RunCalculator.py +++ b/RunCalculator.py @@ -3,7 +3,6 @@ from calculators.Inference import Inference from calculators.Training import Training from calculators.ModelFLOPS import MLPCalculator, CNNCalculator, KANCalculator, TransformerCalculator -#import calculators.ToyModels as toy_models from configs import CalculatorConfig as cfg @@ -75,55 +74,6 @@ def calculate_total_energy(): else: calculator = None - # if cfg.MODEL_NAME == "SimpleMLP": - # model = toy_models.SimpleMLP() - # training = Training( - # model_name=model, - # num_epochs=cfg.NUM_EPOCHS, - # batch_size=cfg.BATCH_SIZE, - # processor_flops_per_second=cfg.TR_PROCESSOR_FLOPS_PER_SECOND, - # processor_max_power=cfg.TR_PROCESSOR_MAX_POWER, - # num_samples=cfg.NUM_SAMPLES, - # input_size=cfg.INPUT_SIZE, - # evaluation_strategy=cfg.EVALUATION_STRATEGY, - # k_folds=cfg.K_FOLDS, - # split_ratio=cfg.SPLIT_RATIO, - # calculator=calculator - # ) - # inference = Inference( - # model_name=model, - # input_size=cfg.INPUT_SIZE, - # num_samples=cfg.NUM_INFERENCES, - # processor_flops_per_second=cfg.INF_PROCESSOR_FLOPS_PER_SECOND, - # processor_max_power=cfg.INF_PROCESSOR_MAX_POWER, - # calculator=calculator - # ) - # elif cfg.MODEL_NAME == "SimpleCNN": - # model = toy_models.SimpleCNN() - - # training = Training( - # model_name=model, - # num_epochs=cfg.NUM_EPOCHS, - # batch_size=cfg.BATCH_SIZE, - # processor_flops_per_second=cfg.TR_PROCESSOR_FLOPS_PER_SECOND, - # processor_max_power=cfg.TR_PROCESSOR_MAX_POWER, - # num_samples=cfg.NUM_SAMPLES, - # input_size=cfg.INPUT_SIZE, - # evaluation_strategy=cfg.EVALUATION_STRATEGY, - # k_folds=cfg.K_FOLDS, - # split_ratio=cfg.SPLIT_RATIO, - # calculator=calculator - # ) - - # inference = Inference( - # model_name=model, - # input_size=cfg.INPUT_SIZE, - # num_samples=cfg.NUM_INFERENCES, - # processor_flops_per_second=cfg.INF_PROCESSOR_FLOPS_PER_SECOND, - # processor_max_power=cfg.INF_PROCESSOR_MAX_POWER, - # calculator=calculator - # ) - # else: training = Training( model_name=cfg.MODEL_NAME, num_epochs=cfg.NUM_EPOCHS, diff --git a/calculators/DataPreprocessing.py b/calculators/DataPreprocessing.py index 52e487c..99d8f3e 100644 --- a/calculators/DataPreprocessing.py +++ b/calculators/DataPreprocessing.py @@ -1,60 +1,4 @@ -from .PreprocessingFLOPS import * +"""Backward-compatibility shim — use ecal.calculators.preprocessing instead.""" +from ecal.calculators.preprocessing import DataPreprocessing # noqa: F401 - -class DataPreprocessing: - """Data preprocessing class that calculates the FLOPs for various data preprocessing tasks""" - - def __init__(self, preprocessing_type: str = 'normalization', processor_flops_per_second: float = 1e12, - processor_max_power: int = 100, time_steps: int = 1): - """ - Initialize DataPreprocessing class - - Args: - preprocessing_type: Type of preprocessing to perform - """ - self.calculators = { - 'normalization': NormalizationCalculator(), - 'min_max_scaling': MinMaxScalingCalculator(), - 'GADF': GramianDifferenceFieldCalculator() - } - self.preprocessing_type = preprocessing_type - self.set_preprocessing_type(preprocessing_type) - self.processor_flops_per_second = processor_flops_per_second - self.processor_max_power = processor_max_power - - def set_preprocessing_type(self, preprocessing_type: str) -> None: - """Set the preprocessing type""" - if preprocessing_type not in self.calculators: - raise ValueError(f"Unsupported preprocessing type: {preprocessing_type}") - self.calculator = self.calculators[preprocessing_type] - - def calculate_flops(self, data_bits: int, time_steps=1) -> float: - """ - Calculate FLOPs for the current preprocessing type - - Args: - data_bits: Number of bits in the input data - time_steps: Number of time steps in the input time series data - - Returns: - Total FLOPs for the current preprocessing type - """ - if self.preprocessing_type == 'GADF': - return self.calculator.calculate_flops(data_bits, time_steps) - - return self.calculator.calculate_flops(data_bits) - - def calculate_energy(self, data_bits: int, time_steps: int) -> float: - # Calculate the total number of flops - if self.preprocessing_type == 'GADF': - calc_dict = self.calculate_flops(data_bits, time_steps) - else: - calc_dict = self.calculate_flops(data_bits * time_steps) - total_flops = calc_dict['total_flops'] - - total_time = total_flops / self.processor_flops_per_second - total_energy = total_time * self.processor_max_power - return { - "total_energy": total_energy, - "total_bits": data_bits * time_steps, - } +__all__ = ["DataPreprocessing"] diff --git a/calculators/Inference.py b/calculators/Inference.py index a06a67a..e16f337 100644 --- a/calculators/Inference.py +++ b/calculators/Inference.py @@ -1,61 +1,4 @@ -from typing import Dict, Union, Tuple, Optional -from .ModelFLOPS import FLOPCalculator, FlopsCalculatorFactory -from torchvision.models import resnet18 +"""Backward-compatibility shim — use ecal.calculators.inference instead.""" +from ecal.calculators.inference import Inference # noqa: F401 - -class Inference: - """ - This class is used to estimate the flops of the model inference, which is then used to estimate - the energy consumption of the model inference. - """ - - def __init__(self, model_name: str, input_size: Tuple, num_samples: int, processor_flops_per_second: float, - processor_max_power: int, calculator: Optional[FLOPCalculator] = None): - """ - Initialize Inference class - Args: - calculator: FLOPCalculator implementation - model_name: PyTorch model or model name - input_size: Tuple of input size - num_samples: int of number of samples - processor_flops_per_second: float of processor flops per second - processor_max_power: int of processor max power in watts - """ - if model_name == 'resnet18': - self.model = resnet18() - else: - self.model = model_name - - if calculator is not None: - self.calculator = calculator - else: - self.calculator = FlopsCalculatorFactory.create_calculator(self.model) - self.input_size = input_size - self.num_samples = num_samples - # hardware parameters - self.processor_flops_per_second = processor_flops_per_second - self.processor_max_power = processor_max_power - - def calculate_flops(self) -> Dict[str, Union[int, Dict]]: - """ - - Returns: - Total FLOPs for the current inference - """ - forward_flops = self.calculator.calculate(self.model, self.input_size)['total_flops'] - total_flops = forward_flops * self.num_samples - return total_flops - - def calculate_energy(self) -> float: - """ - Calculate the energy usage of the current inference - - Returns: - Total energy usage of the current inference in Joules - """ - # Calculate the total number of flops - total_flops = self.calculate_flops() - # Calculate the total energy usage - total_time = total_flops / self.processor_flops_per_second - total_energy = total_time * self.processor_max_power - return total_energy +__all__ = ["Inference"] diff --git a/calculators/ModelFLOPS.py b/calculators/ModelFLOPS.py index bf1c46d..025fd57 100644 --- a/calculators/ModelFLOPS.py +++ b/calculators/ModelFLOPS.py @@ -1,263 +1,22 @@ -from abc import ABC, abstractmethod -from typing import Dict, Union, Tuple - -from calflops import calculate_flops_hf, calculate_flops -from torch import nn - - -class FlopsCalculatorFactory: - @staticmethod - def create_calculator(model: Union[nn.Module, str]) -> 'FLOPCalculator': - if isinstance(model, str): - return CalFlopsCalculatorHF() - elif isinstance(model, nn.Module): - return CalFlopsCalculatorPT() - else: - raise ValueError("Model must be either a string (HuggingFace model name) or nn.Module (PyTorch model)") - - -class FLOPCalculator(ABC): - @abstractmethod - def calculate(self, model: Union[nn.Module, str], input_size: Tuple) -> Dict[str, Union[int, Dict]]: - pass - - -class CalFlopsCalculatorHF(FLOPCalculator): - def calculate(self, model: str, input_size: Tuple) -> Dict[str, Union[int, Dict]]: - flops, macs, params = calculate_flops_hf(model_name=model, input_shape=input_size, print_results=False, - output_as_string=False) - return {"total_flops": flops, "total_params": params} - - -class CalFlopsCalculatorPT(FLOPCalculator): - def calculate(self, model: nn.Module, input_size: Tuple) -> Dict[str, Union[int, Dict]]: - flops, macs, params = calculate_flops(model=model, - input_shape=input_size, - output_as_string=False, - output_precision=4, - print_results=False) - return {"total_flops": flops, "total_params": params} - -class MLPCalculator(FLOPCalculator): - def __init__(self, num_layers: int, din: int, dout: int, num_samples: int = 1, - num_classes: int = 2): - self.din = din - self.dout = dout - self.L = num_layers - self.T = num_samples - self.C = num_classes - - def calculate(self, model: nn.Module, input_size: Tuple) -> Dict[str, Union[int, Dict]]: - """ - Calculate FLOPs and parameters for a Multilayer Perceptron - - Parameters: - - L: Number of layers - - M_l-1: Input dimension of the layer - - M_l: Output dimension of the layer - """ - L = self.L # Number of layers - - # Total FLOPs calculation following the new formula - total_flops = 0 - for l in range(0, L - 1): - - # FLOPs from input-output dimension computation - layer_flops = 2 * (self.din * self.din) + 2 * self.din - - total_flops += layer_flops - - return { - 'total_flops': int(total_flops), - 'total_params': None - } - -class CNNCalculator(FLOPCalculator): - def __init__(self, num_cnv_layers: int = 3, num_pool_layers: int = 1, i_r: int = 10, i_c: int = 1, - k_r: int = 3, k_c: int = 1, c_in: int = 1, s_r: int = 1, s_c: int = 1, N_f: int = 3, num_samples: int = 1, - num_classes: int = 2): - self.num_cnv_layers = num_cnv_layers - self.num_pool_layers = num_pool_layers - self.i_r = i_r - self.i_c = i_c - self.k_r = k_r - self.k_c = k_c - self.p_r = k_r - 1 - self.p_c = k_c - self.c_in = c_in - self.s_r = s_r - self.s_c = s_c - self.N_f = N_f - self.T = num_samples - self.C = num_classes - - def calculate(self, model: nn.Module, input_size: Tuple) -> Dict[str, Union[int, Dict]]: - """ - Calculate FLOPs and parameters for a Multilayer Perceptron - - Parameters: - - num_cnv_layers: Number of convolutional layers - - num_pool_layers: Number of pooling layers - """ - num_cnv_layers = self.num_cnv_layers # Number of layers - num_pool_layers = self.num_pool_layers # Number of layers - - input_height = self.i_r - input_width = self.i_c - - output_height = 0 - output_width = 0 - # Total FLOPs calculation following the new formula - total_flops = 0 - for c in range(0, num_cnv_layers - 1): - - # FLOPs from convolutional layers - output_height = (input_height - self.k_r + 2 * self.p_r) / self.s_r + 1 - output_width = (input_width - self.k_c + 2 * self.p_c) / self.s_c + 1 - - # Create convolutional blocks with increasing channel depth - input_height = self.i_r * (2 ** c) - input_width = self.i_c * (2 ** c) - - layer_flops = output_height * output_width * (self.c_in * self.k_r * self.k_c + 1) * self.N_f - - total_flops += layer_flops - - for p in range(0, num_pool_layers): - - # FLOPs from pooling layers - output_height = (input_height - self.k_r + 2 * self.p_r) / self.s_r + 1 - output_width = (input_width - self.k_c + 2 * self.p_c) / self.s_c + 1 - - layer_flops = output_height * output_width * self.c_in - - total_flops += layer_flops - - # add final layer - total_flops += 2 * (output_height * output_width) + 2 * output_height - - return { - 'total_flops': int(total_flops), - 'total_params': None - } - - -class KANCalculator(FLOPCalculator): - def __init__(self, num_layers: int, grid_size: int, din: int, dout: int, k: int = 3, num_samples: int = 1, - num_classes: int = 2): - self.G = grid_size - self.din = din - self.dout = dout - self.k = k - self.L = num_layers - self.T = num_samples - self.C = num_classes - - def calculate(self, model: nn.Module, input_size: Tuple) -> Dict[str, Union[int, Dict]]: - """ - Calculate FLOPs and parameters for a Kolmogorov-Arnold Network (KAN) - - Parameters: - - K: B-spline degree (typically 3) - - G: Grid size - - L: Number of layers - - M_l-1: Input dimension of the layer - - M_l: Output dimension of the layer - - M_NLF: FLOPs for non-linear function (B-spline activation) - """ - K = self.k # B-spline degree - G = self.G # Grid size - L = self.L # Number of layers - - # Constant for B-spline and grid computation - M_B = 9 * K * (G + 1.5 * K) + 2 * G - 2.5 * K + 3 - - # Assuming M_NLF is the FLOPs for B-spline activation function - # This might need to be precisely defined based on the specific implementation - M_NLF = 2 # Placeholder, adjust based on actual B-spline activation computation - - # Total FLOPs calculation following the new formula - total_flops = 0 - for l in range(1, L): - # FLOPs from B-spline activation - b_spline_flops = M_NLF * self.din - - # FLOPs from input-output dimension computation with B-spline transformation - layer_flops = (self.din * self.din) * M_B - - total_flops += b_spline_flops + layer_flops - - return { - 'total_flops': int(total_flops), - 'total_params': None - } - - -class TransformerCalculator(FLOPCalculator): - def __init__(self, context_length: int, embedding_size: int, num_heads: int, - num_decoder_blocks: int, feed_forward_size: int, vocab_size: int): - self.context_length = context_length - self.embedding_size = embedding_size - self.num_heads = num_heads - self.num_decoder_blocks = num_decoder_blocks - self.feed_forward_size = feed_forward_size - self.vocab_size = vocab_size - - def calculate(self, model: nn.Module, input_size: Tuple) -> Dict[str, Union[int, Dict]]: - """ - Calculate FLOPs for a Transformer model. - - Parameters: - - C: Context length - - N_embed: Embedding size - - N_head: Number of attention heads - - N_decoder_blocks: Number of decoder blocks - - FFS: Feed forward size - """ - # Model parameters - C = self.context_length - N_embed = self.embedding_size - N_head = self.num_heads - N_decoder_blocks = self.num_decoder_blocks - FFS = self.feed_forward_size - - # Calculate attention FLOPs (M_ATT) - # K, Q, V positional embedding - kqv_flops = C * N_embed * 3 * N_embed - - # Attention scores - attention_score_flops = C * C * N_embed - - # Reduce operation - reduce_flops = N_head * C * C * (N_embed // N_head) - - # Projection - projection_flops = C * N_embed * N_embed - - # Total attention FLOPs (multiplied by 2 as per equation) - M_ATT = 2 * (kqv_flops + attention_score_flops + reduce_flops + projection_flops) - - # MLP blocks FLOPs - mlp_flops = 2 * 2 * C * N_embed * FFS - - # Total Transformer FLOPs (M_TR) - M_TR = N_decoder_blocks * (M_ATT + mlp_flops) - - total_flops = M_TR - - # Calculate parameters - - return { - 'total_flops': int(total_flops), - 'total_params': None, - 'breakdown': { - 'attention': { - 'kqv_embedding_flops': int(kqv_flops), - 'attention_score_flops': int(attention_score_flops), - 'reduce_flops': int(reduce_flops), - 'projection_flops': int(projection_flops), - 'total_attention_flops': int(M_ATT) - }, - 'mlp_blocks_flops': int(mlp_flops), - 'per_block_flops': int(M_ATT + mlp_flops), - }} +"""Backward-compatibility shim — use ecal.calculators.model_flops instead.""" +from ecal.calculators.model_flops import ( # noqa: F401 + FLOPCalculator, + FlopsCalculatorFactory, + CalFlopsCalculatorHF, + CalFlopsCalculatorPT, + MLPCalculator, + CNNCalculator, + KANCalculator, + TransformerCalculator, +) + +__all__ = [ + "FLOPCalculator", + "FlopsCalculatorFactory", + "CalFlopsCalculatorHF", + "CalFlopsCalculatorPT", + "MLPCalculator", + "CNNCalculator", + "KANCalculator", + "TransformerCalculator", +] diff --git a/calculators/PreprocessingFLOPS.py b/calculators/PreprocessingFLOPS.py index ed7b0b1..b069aa1 100644 --- a/calculators/PreprocessingFLOPS.py +++ b/calculators/PreprocessingFLOPS.py @@ -1,70 +1,14 @@ -from abc import ABC, abstractmethod -from typing import Dict, Union - - -class PreprocessingFLOPCalculator(ABC): - @abstractmethod - def calculate_flops(self, data_size: int) -> Dict[str, Union[int, Dict]]: - pass - - -class NormalizationCalculator(PreprocessingFLOPCalculator): - def calculate_flops(self, data_size: int) -> Dict[str, Union[int, Dict]]: - # calculating mean: - # 1. add all data points -> data_size - 1 - # 2. divide by data_size -> 1 - # Mean calculation FLOPS: data_size - 1 + 1 = data_size - # ------------------------------------------------------------ - # calculating std: - # 1. subtract mean from each data point -> data_size - # 2. square the result -> data_size - # 3. add the squares -> data_size - 1 - # 4. divide by data_size -> 1 - # 5. take the square root -> 1 - # Std. calculation FLOPS: data_size + data_size + (data_size - 1) + 1 + 1 = 3 * data_size + 1 - # ------------------------------------------------------------ - # normalization: - # 1. subtract mean from each data point -> data_size - # 2. divide by std -> data_size - # normalization FLOPS: data_size + data_size = 2 * data_size - # ------------------------------------------------------------ - - # FINAL total FLOPS calculation: data_size + 3 * data_size + 1 + 2 * data_size = 6 * data_size + 1 - - total_flops = (6 * data_size) + 1 - - return {"total_flops": total_flops, - "data_shape": None - } - - -class MinMaxScalingCalculator(PreprocessingFLOPCalculator): - def calculate_flops(self, data_size: int) -> Dict[str, Union[int, Dict]]: - # Min-Max scaling: - # 0. find max and min -> 0 - # 1. calculate max-min -> 1 - # 1. subtract min from each data point -> data_size - # 2. divide by (max - min) -> data_size - # Total FLOPS: 1 + data_size + data_size = 2 * data_size + 1 - - scaling_flops = data_size * 2 + 1 # - - return {"total_flops": scaling_flops, - "data_shape": None - } - - -class GramianDifferenceFieldCalculator(PreprocessingFLOPCalculator): - def calculate_flops(self, data_size: int, time_steps: int) -> Dict[str, Union[int, Dict]]: - # - # 1. perform minmax 2 times -> 2 * data_size +1 - # 2. compute GADF flops based on pyTS implementation - > (5 * time_steps + time_steps * time_steps) * data_size - minmax_calculator = MinMaxScalingCalculator() - minmax_flops = minmax_calculator.calculate_flops(data_size * time_steps)["total_flops"] - - gadf_flops = (5 * time_steps + time_steps * time_steps) * data_size - total_flops = minmax_flops + gadf_flops - return { - "total_flops": total_flops, - "data_shape": (data_size, time_steps, time_steps) - } +"""Backward-compatibility shim — use ecal.calculators.preprocessing_flops instead.""" +from ecal.calculators.preprocessing_flops import ( # noqa: F401 + PreprocessingFLOPCalculator, + NormalizationCalculator, + MinMaxScalingCalculator, + GramianDifferenceFieldCalculator, +) + +__all__ = [ + "PreprocessingFLOPCalculator", + "NormalizationCalculator", + "MinMaxScalingCalculator", + "GramianDifferenceFieldCalculator", +] diff --git a/calculators/ToyModels.py b/calculators/ToyModels.py index 25b4306..d75fbd4 100644 --- a/calculators/ToyModels.py +++ b/calculators/ToyModels.py @@ -8,22 +8,22 @@ class SimpleMLP(nn.Module): def __init__(self, input_size=10, hidden_size=10, output_size=2, num_layers=3): super(SimpleMLP, self).__init__() - + # Create a ModuleList to store variable number of layers self.layers = nn.ModuleList() - + # First layer (input to hidden) self.layers.append(nn.Linear(input_size, hidden_size)) self.layers.append(nn.ReLU()) - + # Hidden layers for _ in range(num_layers - 1): self.layers.append(nn.Linear(hidden_size, hidden_size)) self.layers.append(nn.ReLU()) - + # Output layer self.output = nn.Linear(hidden_size, output_size) - + def forward(self, x): # Pass through all layers sequentially for layer in self.layers: @@ -32,46 +32,46 @@ def forward(self, x): class SimpleCNN(nn.Module): def __init__(self, input_channels=1, hidden_channels=10, output_size=2, num_layers=3): super(SimpleCNN, self).__init__() - + self.layers = nn.ModuleList() current_channels = input_channels - + # Create convolutional layers for i in range(num_layers): # More controlled channel growth out_channels = hidden_channels * (2 if i > 0 else 1) - + # Create conv block with standard pooling conv_block = nn.Sequential( nn.Conv1d(current_channels, out_channels, kernel_size=3, padding=1), nn.BatchNorm1d(out_channels), nn.ReLU(), ) - + self.layers.append(conv_block) current_channels = out_channels - + # Global average pooling self.global_pool = nn.AvgPool1d(kernel_size=2) - + # Output layer self.output = nn.Linear(current_channels, output_size) - + def forward(self, x): # Pass through all convolutional blocks for layer in self.layers: x = layer(x) - - + + # Ensure we have at least one feature if x.size(-1) > 1: x = self.global_pool(x) - + # Global average pooling x = torch.mean(x, dim=-1) - + return self.output(x) - + class SimpleMLP_practical(nn.Module): def __init__(self, input_size=10, hidden_size=10, output_size=2, num_layers=3): @@ -103,8 +103,8 @@ def __init__(self, input_size=10, output_size=2, hidden_channels=16, num_layers= super(SimpleCNN_practical, self).__init__() self.layers = nn.ModuleList() # Input data is expected to be reshaped to have 1 channel. - current_channels = 1 - + current_channels = 1 + # Create convolutional blocks with increasing channel depth for i in range(1, num_layers): out_channels = hidden_channels * (2 ** i) @@ -115,11 +115,11 @@ def __init__(self, input_size=10, output_size=2, hidden_channels=16, num_layers= ) self.layers.append(conv_block) current_channels = out_channels - + # Global pooling layer adapts to any input size self.global_pool = nn.AdaptiveAvgPool1d(1) self.output_layer = nn.Linear(current_channels, output_size) - + def forward(self, x): #if x.dim() == 2: @@ -130,7 +130,7 @@ def forward(self, x): #x = self.global_pool(x) #x = x.view(x.size(0), -1) # Flatten the output for the linear layer return x #self.output_layer(x) - + # --- Simplified KAN-like Model (Modified to vary sub-layers) --- class KANLikeRegressor(nn.Module): @@ -140,7 +140,7 @@ def __init__(self, num_layers: int, grid_size: int = 10, din: int = 10, dout: in architecture = [din] + [din] * (num_layers) + [dout] - self.kan = KAN(architecture, grid_size=grid_size, + self.kan = KAN(architecture, grid_size=grid_size, spline_order=3) def forward(self, x): @@ -155,77 +155,77 @@ def forward(self, x): class MultiHeadSelfAttention(nn.Module): def __init__(self, num_emb, num_heads=8): super().__init__() - + # hyperparams self.D = num_emb # embedding size self.H = num_heads # number of transformer heads - + # weights for self-attention self.w_k = nn.Linear(self.D, self.D * self.H) self.w_q = nn.Linear(self.D, self.D * self.H) self.w_v = nn.Linear(self.D, self.D * self.H) - + # weights for a combination of multiple heads self.w_c = nn.Linear(self.D * self.H, self.D) - + def forward(self, x, causal=True): # x: B(atch) x T(okens) x D(imensionality) B, T, D = x.size() - + # keys, queries, values - k = self.w_k(x).view(B, T, self.H, D) # B x T x H x D ########## K = x*W_k + b_k - q = self.w_q(x).view(B, T, self.H, D) # B x T x H x D ########## Q = x*W_q + b_q - v = self.w_v(x).view(B, T, self.H, D) # B x T x H x D ########## V = x*W_v + b_v - + k = self.w_k(x).view(B, T, self.H, D) # B x T x H x D ########## K = x*W_k + b_k + q = self.w_q(x).view(B, T, self.H, D) # B x T x H x D ########## Q = x*W_q + b_q + v = self.w_v(x).view(B, T, self.H, D) # B x T x H x D ########## V = x*W_v + b_v + # batches and heads are merged for more efficent matrix multiplication # B x T x H x D -> B*H x T x D - k = k.transpose(1, 2).contiguous().view(B * self.H, T, D) # B*H x T x D + k = k.transpose(1, 2).contiguous().view(B * self.H, T, D) # B*H x T x D q = q.transpose(1, 2).contiguous().view(B * self.H, T, D) # B*H x T x D v = v.transpose(1, 2).contiguous().view(B * self.H, T, D) # B*H x T x D - - k = k / (D**0.25) # scaling with sqrt(D) + + k = k / (D**0.25) # scaling with sqrt(D) q = q / (D**0.25) # scaling with sqrt(D) - + # kq kq = torch.bmm(q, k.transpose(1, 2)) # B*H x T x T # (Q x K^T) / sqrt(D) - + # if causal apply mask to prevent information flow from future tokens we set tokens above the diagonal to -inf so after softmax they are 0 if causal: mask = torch.triu_indices(T, T, offset=1) kq[..., mask[0], mask[1]] = float('-inf') - + # softmax skq = F.softmax(kq, dim=2) # B*H x T x T | A = softmax((Q x K^T)/sqrt(D)) - + # self-attention sa = torch.bmm(skq, v) # B*H x T x D # (softmax(Q x K^T) x V) sa = sa.view(B, self.H, T, D) # B x H x T x D sa = sa.transpose(1, 2) # B x T x H x D sa = sa.contiguous().view(B, T, D * self.H) # B x T x D*H - + out = self.w_c(sa) # B x T x D - - return out - + + return out + class TransformerBlock(nn.Module): def __init__(self, num_emb, num_neurons, num_heads=4): super().__init__() - + # hyperparams self.D = num_emb self.H = num_heads self.neurons = num_neurons - + # components self.msha = MultiHeadSelfAttention(num_emb=self.D, num_heads=self.H) self.layer_norm1 = nn.LayerNorm(self.D) self.layer_norm2 = nn.LayerNorm(self.D) - + self.mlp = nn.Sequential(nn.Linear(self.D, self.neurons * self.D), nn.GELU(), nn.Linear(self.neurons * self.D, self.D)) - + def forward(self, x, causal=True): # Multi-Head Self-Attention x_attn = self.msha(x, causal) @@ -235,27 +235,27 @@ def forward(self, x, causal=True): x_mlp = self.mlp(x) # LayerNorm x = self.layer_norm2(x_mlp + x) - - return x - + + return x + class LossFun(nn.Module): def __init__(self,): super().__init__() - + self.loss = nn.MSELoss() - + def forward(self, y_model, y_true, reduction='sum'): # y_model: B(atch) x T(okens) x V(alues) - # y_true: B x T + # y_true: B x T B, T, V = y_model.size() - + y_model = y_model.view(B * T, V) y_true = y_true.view(B * T,) - + loss_matrix = self.loss(y_model, y_true) # B*T - + if reduction == 'sum': return torch.sum(loss_matrix) elif reduction == 'mean': @@ -263,11 +263,11 @@ def forward(self, y_model, y_true, reduction='sum'): return torch.mean(torch.sum(loss_matrix, 1)) else: raise ValueError('Reduction could be either `sum` or `mean`.') - + class Transformer(nn.Module): def __init__(self, num_tokens, num_token_vals, num_emb, num_neurons, num_heads=2, dropout_prob=0.1, num_blocks=10, device='cpu'): super().__init__() - + # hyperparams self.device = device self.num_tokens = num_tokens @@ -328,4 +328,4 @@ def transformer_forward(self, x, causal=True): def forward(self, x, causal=True): # This method just calls the main forward pass - return self.transformer_forward(x, causal=causal) \ No newline at end of file + return self.transformer_forward(x, causal=causal) diff --git a/calculators/Training.py b/calculators/Training.py index b0c69fa..fad3c45 100644 --- a/calculators/Training.py +++ b/calculators/Training.py @@ -1,115 +1,4 @@ -from typing import Dict, Union, Tuple, Optional +"""Backward-compatibility shim — use ecal.calculators.training instead.""" +from ecal.calculators.training import Training # noqa: F401 -# import resnet18 -from torchvision.models import resnet18 - -from .ModelFLOPS import FLOPCalculator, FlopsCalculatorFactory - - -class Training: - """ - This class is used to estimate the flops of the model training, which is then used to estimate - the energy consumption of the model training. - """ - - def __init__(self, model_name: str, - batch_size: int, num_epochs: int, num_samples: int, - processor_flops_per_second: float, processor_max_power: int, input_size: Tuple, - evaluation_strategy: str, k_folds: int, split_ratio: float, - calculator: Optional[FLOPCalculator] = None): - """ - Initialize Training class with optional custom FLOP calculator - - Args: - calculator: Optional custom FLOPCalculator implementation - input_size: Tuple of input size - batch_size: int of batch size - num_epochs: int of number of epochs - num_samples: int of number of samples - processor_flops_per_second: float of processor flops per second - processor_max_power: int of processor max power in watts - evaluation_strategy: str of evaluation strategy - k_folds: int of number of folds for cross-validation - split_ratio: float of split ratio for train-test split - """ - if model_name == 'resnet18': - self.model = resnet18() - else: - self.model = model_name - - if calculator is not None: - self.calculator = calculator - else: - self.calculator = FlopsCalculatorFactory.create_calculator(self.model) - - if evaluation_strategy == 'train_test_split': - self.evaluation_strategy = 'train_test_split' - self.split_ratio = split_ratio - elif evaluation_strategy == 'cross_validation': - self.evaluation_strategy = 'cross_validation' - self.k_folds = k_folds - else: - raise ValueError(f"Unsupported evaluation strategy: {evaluation_strategy}") - - self.input_size = input_size - self.batch_size = batch_size - self.num_epochs = num_epochs - self.num_samples = num_samples - # hardware parameters - self.processor_flops_per_second = processor_flops_per_second - self.processor_max_power = processor_max_power - - def calculate_flops_training(self) -> Dict[str, Union[int, Dict]]: - - forward_flops = self.calculator.calculate(self.model, self.input_size)['total_flops'] - # 1 training pass takes roughly 3x a single forward pass - training_flops = forward_flops * 3 - # Calculate the number of batches - if self.evaluation_strategy == 'train_test_split': - training_samples = self.num_samples * self.split_ratio - elif self.evaluation_strategy == 'cross_validation': - percentage_of_samples = 1 - (1 / self.k_folds) # percentage of samples used for training - number_of_folds = self.k_folds - training_samples = self.num_samples * percentage_of_samples * number_of_folds - - else: - raise ValueError(f"Unsupported evaluation strategy: {self.evaluation_strategy}") - # Calculate the total number of flops - total_flops = training_flops * training_samples * self.num_epochs - return total_flops - - def calculate_flops_evaluation(self) -> float: - # Calculate the total number of flops - forward_flops = self.calculator.calculate(self.model, self.input_size)['total_flops'] - if self.evaluation_strategy == 'train_test_split': - evaluation_samples = self.num_samples * (1 - self.split_ratio) - elif self.evaluation_strategy == 'cross_validation': - percentage_of_samples = 1 / self.k_folds # percentage of samples used for evaluation - number_of_folds = self.k_folds - evaluation_samples = self.num_samples * percentage_of_samples * number_of_folds - else: - raise ValueError(f"Unsupported evaluation strategy: {self.evaluation_strategy}") - - total_flops = forward_flops * evaluation_samples - return total_flops - - def calculate_energy(self) -> float: - # Calculate the total number of flops - training_flops = self.calculate_flops_training() - evaluation_flops = self.calculate_flops_evaluation() - - training_energy = training_flops / self.processor_flops_per_second * self.processor_max_power - evaluation_energy = evaluation_flops / self.processor_flops_per_second * self.processor_max_power - - # Calculate the total energy usage - total_energy = training_energy + evaluation_energy - - return { - "total_energy": total_energy, - "training_energy": training_energy, - "evaluation_energy": evaluation_energy, - "training_flops": training_flops, - "evaluation_flops": evaluation_flops, - "train_time": self.processor_flops_per_second / training_flops, - "eval_time": self.processor_flops_per_second / evaluation_flops - } +__all__ = ["Training"] diff --git a/calculators/Transmission.py b/calculators/Transmission.py index fb69386..c4fe71b 100644 --- a/calculators/Transmission.py +++ b/calculators/Transmission.py @@ -1,108 +1,4 @@ -from typing import Dict, Union -from configs.ProtocolConfigs import * +"""Backward-compatibility shim — use ecal.calculators.transmission instead.""" +from ecal.calculators.transmission import Transmission # noqa: F401 - -class Transmission: - """ - Simplified calculator for network energy consumption that allows protocol selection - for each OSI layer, focusing only on data and control plane overheads - """ - - def __init__(self, - application: str = 'HTTP', - presentation: str = 'TLS', - session: str = 'RPC', - transport: str = 'TCP', - network: str = 'IPv4', - datalink: str = 'WIFI_MAC', - physical: str = 'WIFI_PHY', - failure_rate: float = 0.0): - """ - Initialize calculator with specific protocols for each layer - - Args: - failure_rate: Probability of transmission failure (0.0 to 1.0) - """ - self.protocols = { - 'application': APPLICATION_PROTOCOLS[application], - 'presentation': PRESENTATION_PROTOCOLS[presentation], - 'session': SESSION_PROTOCOLS[session], - 'transport': TRANSPORT_PROTOCOLS[transport], - 'network': NETWORK_PROTOCOLS[network], - 'datalink': DATALINK_PROTOCOLS[datalink], - 'physical': PHYSICAL_PROTOCOLS[physical] - } - if not 0 <= failure_rate <= 1: - raise ValueError("Failure rate must be between 0 and 1") - self.failure_rate = failure_rate - - def calculate_layer_energy(self, protocol: LayerProtocol, input_bits: int) -> Dict[str, Union[float, int]]: - """Calculate energy consumption for a single layer""" - - # Calculate overhead bits - data_plane_bits = int(input_bits * protocol.data_plane_overhead) - control_plane_bits = int(input_bits * protocol.control_plane_overhead) - - # Total bits at this layer - total_bits = input_bits + data_plane_bits + control_plane_bits - first_term = total_bits * protocol.base_energy_per_bit_sender - second_term = total_bits * protocol.base_energy_per_bit_receiver - third_term = total_bits * protocol.Niot * protocol.Piot # Niot - fourth_term = total_bits * protocol.Ngateway * protocol.Pgateway # Ngateway - - total_energy = first_term + second_term + third_term + fourth_term - - return { - 'total_bits': total_bits, - 'total_energy': total_energy, - 'breakdown': { - 'first_term': first_term, - 'second_term': second_term, - 'third_term': third_term, - 'fourth_term': fourth_term - } - } - - def calculate_energy(self, data_bits: int) -> Dict[str, Union[float, Dict]]: - """Calculate energy consumption with retransmission consideration""" - base_result = self._calculate_single_transmission(data_bits) - - # Calculate expected number of transmissions using geometric distribution - # E[X] = 1/(1-p) where p is failure rate - expected_transmissions = 1 / (1 - self.failure_rate) - - total_energy = base_result['total_energy'] * expected_transmissions - total_bits = base_result['total_bits'] * expected_transmissions - - return { - 'total_energy': total_energy, - 'total_bits': total_bits, - 'original_bits': data_bits, - 'expected_transmissions': expected_transmissions, - 'failure_rate': self.failure_rate, - 'single_transmission': base_result, - 'layer_breakdown': base_result['layer_breakdown'] - } - - def _calculate_single_transmission(self, data_bits: int) -> Dict[str, Union[float, Dict]]: - """Original calculation logic for a single transmission""" - current_bits = data_bits - total_energy = 0 - layer_results = {} - - for layer_name, protocol in self.protocols.items(): - curr_layer_result = self.calculate_layer_energy(protocol, current_bits) - layer_results[layer_name] = { - 'protocol': protocol.name, - 'energy': curr_layer_result['total_energy'], - 'breakdown': curr_layer_result['breakdown'] - } - total_energy += curr_layer_result['total_energy'] - current_bits = curr_layer_result['total_bits'] - - return { - 'total_energy': total_energy, - 'total_bits': current_bits, - 'original_bits': data_bits, - 'layer_breakdown': layer_results - } +__all__ = ["Transmission"] diff --git a/calculators/__init__.py b/calculators/__init__.py index e69de29..ad62c17 100644 --- a/calculators/__init__.py +++ b/calculators/__init__.py @@ -0,0 +1 @@ +"""Backward-compatibility shim — imports from ecal.calculators.""" diff --git a/configs/ProtocolConfigs.py b/configs/ProtocolConfigs.py index 39abb80..88938f4 100644 --- a/configs/ProtocolConfigs.py +++ b/configs/ProtocolConfigs.py @@ -1,260 +1,11 @@ -from dataclasses import dataclass - - -@dataclass -class LayerProtocol: - """Protocol metrics for a single layer""" - name: str # Protocol name - data_plane_overhead: float # Data plane overhead ratio - control_plane_overhead: float # Control plane overhead ratio - base_energy_per_bit_sender: float # Energy consumption per bit sender - base_energy_per_bit_receiver: float # Energy consumption per bit - Niot: int - Piot: float - Ngateway: int - Pgateway: float - - -# Protocol configurations for each layer -APPLICATION_PROTOCOLS = { - 'HTTP': LayerProtocol( - name='HTTP', - data_plane_overhead=0.1, # 5% headers and data formatting - control_plane_overhead=0.05, # 2% control messages - base_energy_per_bit_sender=0.00000001, # 10 nJ/bit - base_energy_per_bit_receiver=0.00000001, # 10 nJ/bit - Niot=100, - Piot=2 * 1e-10, - Ngateway=100, - Pgateway=1e-10 - ), - 'FTP': LayerProtocol( - name='FTP', - data_plane_overhead=0.03, - control_plane_overhead=0.04, - base_energy_per_bit_sender=0.00000001, - base_energy_per_bit_receiver=0.00000001, - Niot=100, - Piot=2 * 1e-10, - Ngateway=100, - Pgateway=1e-10 - ), - 'Generic_application': LayerProtocol( - name='Generic_application', - data_plane_overhead=0.1, - control_plane_overhead=0.05, - base_energy_per_bit_sender=2e-08, - base_energy_per_bit_receiver=5e-10, - Niot=100, - Piot=2 * 1e-10, - Ngateway=100, - Pgateway=1e-10 - ) -} - -PRESENTATION_PROTOCOLS = { - 'TLS': LayerProtocol( - name='TLS', - data_plane_overhead=0.08, # 8% encryption overhead - control_plane_overhead=0.03, # 3% handshake - base_energy_per_bit_sender=0.00000002, # 20 nJ/bit - base_energy_per_bit_receiver=0.00000002, # 20 nJ/bit - Niot=100, - Piot=2 * 1e-10, - Ngateway=100, - Pgateway=1e-10 - ), - 'SSL': LayerProtocol( - name='SSL', - data_plane_overhead=0.07, - control_plane_overhead=0.04, - base_energy_per_bit_sender=0.00000002, - base_energy_per_bit_receiver=0.00000002, - Niot=100, - Piot=2 * 1e-10, - Ngateway=100, - Pgateway=1e-10 - ), - 'Generic_presentation': LayerProtocol( - name='Generic_presentation', - data_plane_overhead=0.1, - control_plane_overhead=0.05, - base_energy_per_bit_sender=2e-08, - base_energy_per_bit_receiver=5e-10, - Niot=100, - Piot=2 * 1e-10, - Ngateway=100, - Pgateway=1e-10 - ) -} - -SESSION_PROTOCOLS = { - 'RPC': LayerProtocol( - name='RPC', - data_plane_overhead=0.02, - control_plane_overhead=0.02, - base_energy_per_bit_sender=0.00000001, - base_energy_per_bit_receiver=0.00000001, - Niot=100, - Piot=2 * 1e-10, - Ngateway=100, - Pgateway=1e-10 - ), - 'Generic_session': LayerProtocol( - name='Generic_session', - data_plane_overhead=0.1, - control_plane_overhead=0.05, - base_energy_per_bit_sender=2e-08, - base_energy_per_bit_receiver=5e-10, - Niot=100, - Piot=2 * 1e-10, - Ngateway=100, - Pgateway=1e-10 - ) -} - -TRANSPORT_PROTOCOLS = { - 'TCP': LayerProtocol( - name='TCP', - data_plane_overhead=0.05, # 5% segmentation - control_plane_overhead=0.10, # 10% ACKs and control - base_energy_per_bit_sender=0.00000002, # 20 nJ/bit - base_energy_per_bit_receiver=0.00000002, # 20 nJ/bit - Niot=100, - Piot=2 * 1e-10, - Ngateway=100, - Pgateway=1e-10 - ), - 'UDP': LayerProtocol( - name='UDP', - data_plane_overhead=0.02, - control_plane_overhead=0.01, - base_energy_per_bit_sender=0.00000001, - base_energy_per_bit_receiver=0.00000001, - Niot=100, - Piot=2 * 1e-10, - Ngateway=100, - Pgateway=1e-10 - ), - 'Generic_transport': LayerProtocol( - name='Generic_transport', - data_plane_overhead=0.1, - control_plane_overhead=0.05, - base_energy_per_bit_sender=2e-08, - base_energy_per_bit_receiver=5e-10, - Niot=100, - Piot=2 * 1e-10, - Ngateway=100, - Pgateway=1e-10 - - ) - -} - -NETWORK_PROTOCOLS = { - 'IPv4': LayerProtocol( - name='IPv4', - data_plane_overhead=0.03, - control_plane_overhead=0.05, - base_energy_per_bit_sender=0.00000002, - base_energy_per_bit_receiver=0.00000002, - Niot=100, - Piot=2 * 1e-10, - Ngateway=100, - Pgateway=1e-10 - ), - 'IPv6': LayerProtocol( - name='IPv6', - data_plane_overhead=0.04, - control_plane_overhead=0.05, - base_energy_per_bit_sender=0.00000002, - base_energy_per_bit_receiver=0.00000002, - Niot=100, - Piot=2 * 1e-10, - Ngateway=100, - Pgateway=1e-10 - ), - 'Generic_network': LayerProtocol( - name='Generic_network', - data_plane_overhead=0.1, - control_plane_overhead=0.05, - base_energy_per_bit_sender=2e-08, - base_energy_per_bit_receiver=5e-10, - Niot=100, - Piot=2 * 1e-10, - Ngateway=100, - Pgateway=1e-10 - ) -} - -DATALINK_PROTOCOLS = { - 'ETHERNET': LayerProtocol( - name='ETHERNET', - data_plane_overhead=0.05, - control_plane_overhead=0.05, - base_energy_per_bit_sender=0.00000003, - base_energy_per_bit_receiver=0.00000003, - Niot=100, - Piot=2 * 1e-10, - Ngateway=100, - Pgateway=1e-10 - ), - 'WIFI_MAC': LayerProtocol( - name='WIFI_MAC', - data_plane_overhead=0.06, - control_plane_overhead=0.08, - base_energy_per_bit_sender=0.00000004, - base_energy_per_bit_receiver=0.00000004, - Niot=100, - Piot=2 * 1e-10, - Ngateway=100, - Pgateway=1e-10 - ), - 'Generic_datalink': LayerProtocol( - name='Generic_datalink', - data_plane_overhead=0.1, - control_plane_overhead=0.05, - base_energy_per_bit_sender=2e-08, - base_energy_per_bit_receiver=5e-10, - Niot=100, - Piot=2 * 1e-10, - Ngateway=100, - Pgateway=1e-10 - ) -} - -PHYSICAL_PROTOCOLS = { - 'WIFI_PHY': LayerProtocol( - name='WIFI_PHY', - data_plane_overhead=0.10, - control_plane_overhead=0.15, - base_energy_per_bit_sender=0.0000001, - base_energy_per_bit_receiver=0.0000001, - Niot=100, - Piot=2 * 1e-10, - Ngateway=100, - Pgateway=1e-10 - ), - 'BLUETOOTH': LayerProtocol( - name='BLUETOOTH', - data_plane_overhead=0.08, - control_plane_overhead=0.12, - base_energy_per_bit_sender=0.00000005, - base_energy_per_bit_receiver=0.00000005, - Niot=100, - Piot=2 * 1e-10, - Ngateway=100, - Pgateway=1e-10 - ), - 'Generic_physical': LayerProtocol( - name='Generic_physical', - data_plane_overhead=0.1, - control_plane_overhead=0.05, - base_energy_per_bit_sender=2e-08, - base_energy_per_bit_receiver=5e-10, - Niot=100, - Piot=2 * 1e-10, - Ngateway=100, - Pgateway=1e-10 - ) -} +"""Backward-compatibility shim — use ecal.configs.protocol_configs instead.""" +from ecal.configs.protocol_configs import ( # noqa: F401 + LayerProtocol, + APPLICATION_PROTOCOLS, + PRESENTATION_PROTOCOLS, + SESSION_PROTOCOLS, + TRANSPORT_PROTOCOLS, + NETWORK_PROTOCOLS, + DATALINK_PROTOCOLS, + PHYSICAL_PROTOCOLS, +) diff --git a/configs/SimpleMultiModelConfig.py b/configs/SimpleMultiModelConfig.py index 5d27376..31d97d6 100644 --- a/configs/SimpleMultiModelConfig.py +++ b/configs/SimpleMultiModelConfig.py @@ -37,7 +37,7 @@ #CNN specific parameters NUM_CONV_LAYERS = 3 -NUM_POOl_LAYERS = 3 +NUM_POOL_LAYERS = 3 I_R = 10 I_C = 1 K_R = 3 diff --git a/configs/__init__.py b/configs/__init__.py index e69de29..fccd22e 100644 --- a/configs/__init__.py +++ b/configs/__init__.py @@ -0,0 +1 @@ +"""Backward-compatibility shim for configs.""" diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..270bb19 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation + +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +.PHONY: help clean livehtml Makefile + +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +clean: + rm -rf "$(BUILDDIR)" "$(SOURCEDIR)/api/generated" + +livehtml: + sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)/html" $(SPHINXOPTS) $(O) + +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/.gitkeep b/docs/_static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/_templates/.gitkeep b/docs/_templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..322eac4 --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,18 @@ +# API Reference + +```{eval-rst} +.. autosummary:: + :toctree: generated + + ecal + ecal.api + ecal.cli + ecal.hardware.profiles + ecal.configs.protocol_configs + ecal.calculators.transmission + ecal.calculators.preprocessing + ecal.calculators.preprocessing_flops + ecal.calculators.training + ecal.calculators.inference + ecal.calculators.model_flops +``` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..57cce95 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,46 @@ +# Architecture + +``` + ecal.estimate() + | + +--------+-------+-------+--------+ + | | | | | + Transmission Preproc Training Eval Inference + | | | | | + v v v v v + Protocol FLOP FLOP FLOP FLOP + Configs Calcs Calcs Calcs Calcs + (per model type) + | + Hardware Profile + (FLOPS, power, TDP) + | + Energy = time * power + | + eCAL = total_E / total_bits +``` + +{py:func}`ecal.api.estimate` orchestrates the full lifecycle by composing five +stages, each backed by its own calculator class: + +- **Transmission** — {py:class}`ecal.calculators.transmission.Transmission` + estimates the energy of moving data across a configurable OSI protocol + stack, using per-layer parameters from + {py:mod}`ecal.configs.protocol_configs`. +- **Preprocessing** — {py:class}`ecal.calculators.preprocessing.DataPreprocessing` + estimates the FLOPs (and resulting energy) of normalizing, scaling, or + encoding input data, via one of the calculators in + {py:mod}`ecal.calculators.preprocessing_flops`. +- **Training** — {py:class}`ecal.calculators.training.Training` estimates + FLOPs for the training and evaluation passes over the dataset, using a + model-specific FLOP calculator from + {py:mod}`ecal.calculators.model_flops`. +- **Inference** — {py:class}`ecal.calculators.inference.Inference` estimates + FLOPs for a batch of inference calls, reusing the same model-specific FLOP + calculator as training. + +Each stage converts its FLOP estimate into energy using a hardware profile +({py:class}`ecal.hardware.profiles.HardwareProfile`) — a FLOPS-per-second +throughput figure and a power draw in watts. `estimate()` sums the energy +across all stages and divides by the total number of bits processed to +produce the **eCAL** metric (J/bit). diff --git a/docs/citation.md b/docs/citation.md new file mode 100644 index 0000000..3f1a135 --- /dev/null +++ b/docs/citation.md @@ -0,0 +1,44 @@ +# Citation + +If you use this tool please cite our [paper](https://ieeexplore.ieee.org/abstract/document/11298182): + +```bibtex +@ARTICLE{11298182, + author={Chou, Shih-Kai and Hribar, Jernej and Hanžel, Vid and Mohorčič, Mihael and Fortuna, Carolina}, + journal={IEEE Journal on Selected Areas in Communications}, + title={The Energy Cost of Artificial Intelligence Lifecycle in Communication Networks}, + year={2026}, + volume={44}, + number={}, + pages={2427-2443}, + keywords={Artificial intelligence;Measurement;Costs;Energy consumption;Carbon dioxide;Training;Standards;Data centers;Open systems;Energy efficiency;AI model lifecycle;energy consumption;carbon footprint;metric;methodology}, + doi={10.1109/JSAC.2025.3642835}} +``` + +## Related work + +```bibtex +@INPROCEEDINGS{11349371, + author={Chou, Shih-Kai and Hribar, Jernej and Bertalanič, Blaž and Mohorčič, Mihael and Lagkas, Thomas and Sarigiannidis, Panagiotis and Fortuna, Carolina}, + booktitle={2025 IEEE Conference on Network Function Virtualization and Software-Defined Networking (NFV-SDN)}, + title={Energy Cost of the AI/ML Workflow in O-RAN}, + year={2025}, + volume={}, + number={}, + pages={1-6}, + keywords={Training;Measurement;Adaptation models;Costs;Open RAN;Hardware;Energy efficiency;Complexity theory;Artificial intelligence;Optimization;sustainable 6G networks;O-RAN;AI/ML Workflow;eCAL;lifecycle;energy;Carbon Footprint}, + doi={10.1109/NFV-SDN66355.2025.11349371}} +``` + +```bibtex +@INPROCEEDINGS{10849732, + author={Chou, Shih-Kai and Hribar, Jernej and Mohorčič, Mihael and Fortuna, Carolina}, + booktitle={2024 IEEE Conference on Standards for Communications and Networking (CSCN)}, + title={Towards the Standardization of Energy Efficiency Metrics of the AI Lifecycle in 6G and Beyond}, + year={2024}, + volume={}, + number={}, + pages={187-190}, + keywords={Measurement;6G mobile communication;Energy consumption;Costs;Energy measurement;Energy efficiency;Computational efficiency;Quality of experience;Artificial intelligence;Standards;6G;AI-native network;energy efficiency}, + doi={10.1109/CSCN63874.2024.10849732}} +``` diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..6179337 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,71 @@ +# CLI Reference + +The `ecal` console script is installed alongside the package +({py:func}`ecal.cli.main`). + +```bash +ecal --version +ecal --help +``` + +## `ecal estimate` + +Estimate the energy cost of an AI model lifecycle. + +```bash +ecal estimate --model MLP --layers 3 --epochs 50 --hardware apple_m2 +``` + +### Common flags + +| Flag | Default | Description | +|---|---|---| +| `--model` | *(required)* | Model architecture: `MLP`, `CNN`, `KAN`, or `Transformer` | +| `--layers` | 3 | Number of layers | +| `--din` | 10 | Input dimension | +| `--dout` | 2 | Output dimension | +| `--epochs` | 50 | Training epochs | +| `--samples` | 1000 | Number of training samples | +| `--sample-size` | 10 | Sample size / number of features | +| `--inferences` | 10000 | Number of inference calls | +| `--hardware` | *(none)* | Hardware profile name (see [Configuration](configuration.md)) | +| `--json` | off | Output the result as JSON instead of a formatted table | + +### Transformer-specific flags + +| Flag | Default | +|---|---| +| `--context-length` | 10 | +| `--embedding-size` | 16 | +| `--num-heads` | 2 | +| `--decoder-blocks` | 3 | +| `--feed-forward-size` | 32 | +| `--vocab-size` | 2 | + +### CNN-specific flags + +| Flag | Default | +|---|---| +| `--conv-layers` | 3 | +| `--pool-layers` | 3 | + +### KAN-specific flags + +| Flag | Default | +|---|---| +| `--grid-size` | 10 | + +## `ecal profiles` + +List all available hardware profiles: + +```bash +ecal profiles +``` + +## Global flags + +| Flag | Description | +|---|---| +| `--version` | Print the installed `ecal` version and exit | +| `-v`, `--verbose` | Enable verbose (debug-level) logging | diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..101759c --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,57 @@ +import os +import sys + +sys.path.insert(0, os.path.abspath("../src")) + +from ecal._version import __version__ as ecal_version # noqa: E402 + +project = "eCAL" +copyright = "2026, SensorLab" +author = "SensorLab" +version = ecal_version +release = ecal_version + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx.ext.autosummary", + "sphinx.ext.viewcode", + "sphinx.ext.intersphinx", + "myst_parser", +] + +myst_enable_extensions = [ + "colon_fence", +] + +autosummary_generate = True +autodoc_default_options = { + "members": True, + "undoc-members": False, + "show-inheritance": True, +} +autodoc_typehints = "description" + +napoleon_google_docstring = True +napoleon_numpy_docstring = False +napoleon_include_init_with_doc = True +napoleon_use_param = True +napoleon_use_rtype = True + +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "numpy": ("https://numpy.org/doc/stable/", None), + "torch": ("https://pytorch.org/docs/stable/", None), +} + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +source_suffix = { + ".rst": "restructuredtext", + ".md": "markdown", +} + +html_theme = "furo" +html_static_path = ["_static"] +html_title = f"eCAL {version}" diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..11df9ea --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,41 @@ +# Configuration + +## Protocol configuration + +The OSI-layer protocol parameters used by +{py:class}`ecal.calculators.transmission.Transmission` are defined in +{py:mod}`ecal.configs.protocol_configs`. Each layer (application, +presentation, session, transport, network, data link, physical) has a +dictionary mapping protocol name to a +{py:class}`ecal.configs.protocol_configs.LayerProtocol`, supporting protocols +such as HTTP, TCP, IPv4, WiFi, and Bluetooth. + +## Hardware profiles + +Hardware profiles supply the FLOPS-per-second throughput and power draw (TDP) +used to convert FLOP estimates into energy. They are managed by +{py:mod}`ecal.hardware.profiles` and loaded from a bundled YAML file +(`src/ecal/hardware/data/profiles.yaml`). + +| Profile | FP32 FLOPS | TDP (W) | Device | +|--------------------|-------------|---------|--------| +| `apple_m2` | 3.6 TFLOPS | 22 | mps | +| `nvidia_a100_80gb` | 19.5 TFLOPS | 400 | cuda | +| `nvidia_h100_sxm` | 67 TFLOPS | 700 | cuda | +| `generic_cpu` | 1 TFLOPS | 100 | cpu | +| `generic_edge` | 0.01 TFLOPS | 15 | cpu | + +List them at any time with: + +```bash +ecal profiles +``` + +Or programmatically with {py:func}`ecal.hardware.profiles.list_profiles`. + +```{note} +This page covers the configuration mechanism used by the installable +`ecal` package (`src/ecal/`). The repository's top-level `configs/` and +`RunCalculator.py` are a separate, pre-packaging interface and are not +covered by these docs. +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..6fc1100 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,29 @@ +# eCAL + +Analytical estimation of the energy cost of the AI lifecycle (J/bit). + +eCAL computes the total energy consumed across the full AI model lifecycle — +data transmission, preprocessing, training, evaluation, and inference — using +closed-form FLOP formulas and hardware power profiles. + +Published in **IEEE Journal on Selected Areas in Communications (JSAC), 2026**. + +```{toctree} +:maxdepth: 2 +:hidden: + +installation +quickstart +architecture +cli +configuration +citation +api/index +``` + +## License + +BSD 3-Clause License. See [LICENSE](https://github.com/sensorlab/eCAL/blob/main/LICENSE). + +For contributing/development setup (running tests, linting, type checking), +see the [project README](https://github.com/sensorlab/eCAL#development). diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..ec202fb --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,25 @@ +# Installation + +## From source (recommended for development) + +```bash +git clone https://github.com/sensorlab/eCAL.git +cd eCAL +pip install -e ".[dev]" +``` + +## From PyPI + +```bash +pip install ecal-energy +``` + +## Building these docs + +To build this documentation locally, install the `docs` extra: + +```bash +pip install -e ".[docs]" +cd docs +make html +``` diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..483f467 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,28 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +if "%1" == "" goto help + +if "%1" == "clean" ( + rmdir /s /q "%BUILDDIR%" 2>NUL + rmdir /s /q "%SOURCEDIR%\api\generated" 2>NUL + goto end +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..aa2d2a7 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,47 @@ +# Quickstart + +## Python API + +```python +import ecal + +result = ecal.estimate( + model_type="MLP", + model_params={"num_layers": 3, "din": 10, "dout": 2}, + num_samples=1000, + num_epochs=50, + hardware="apple_m2", +) + +print(f"Total energy: {result['total']:.4f} J") +print(f"eCAL: {result['ecal_j_per_bit']:.2e} J/bit") +``` + +See {py:func}`ecal.api.estimate` for the full parameter reference. + +## CLI + +```bash +# Estimate energy for an MLP +ecal estimate --model MLP --layers 3 --epochs 50 --hardware apple_m2 + +# JSON output +ecal estimate --model Transformer --layers 6 --hardware nvidia_h100_sxm --json + +# List available hardware profiles +ecal profiles + +# Version +ecal --version +``` + +See [CLI reference](cli.md) for the full list of flags. + +## Supported Models + +| Model | FLOP Calculator | Key Parameters | +|-------------|------------------------|-----------------------------------------------------| +| MLP | `MLPCalculator` | `num_layers`, `din`, `dout` | +| CNN | `CNNCalculator` | `num_cnv_layers`, `num_pool_layers`, `i_r`, `k_r` | +| KAN | `KANCalculator` | `num_layers`, `grid_size`, `din`, `dout` | +| Transformer | `TransformerCalculator`| `context_length`, `embedding_size`, `num_heads`, `num_decoder_blocks` | diff --git a/examples/basic_estimate.py b/examples/basic_estimate.py new file mode 100644 index 0000000..8ba4eb7 --- /dev/null +++ b/examples/basic_estimate.py @@ -0,0 +1,31 @@ +"""Example: estimate the energy cost of an MLP's full AI lifecycle with eCAL. + +Run with: + python examples/basic_estimate.py +""" + +import ecal + +# Model architecture: a 3-layer MLP with 10 input features and 2 output classes. +model_params = { + "num_layers": 3, + "din": 10, + "dout": 2, +} + +result = ecal.estimate( + model_type="MLP", + model_params=model_params, + num_samples=1000, # training samples + sample_size=10, # features per sample + num_epochs=50, + num_inferences=10000, # inference calls to amortize the cost over + hardware="apple_m2", # pick a profile from `ecal profiles`, or pass + # processor_flops_per_second / processor_max_power directly +) + +print("Energy breakdown (Joules):") +for stage in ("transmission", "preprocessing", "training", "evaluation", "inference_process"): + print(f" {stage:18s}: {result[stage]:.6f} J") +print(f" {'total':18s}: {result['total']:.6f} J") +print(f"\neCAL metric: {result['ecal_j_per_bit']:.3e} J/bit") diff --git a/examples/full_pipeline.py b/examples/full_pipeline.py new file mode 100644 index 0000000..b7cb2fd --- /dev/null +++ b/examples/full_pipeline.py @@ -0,0 +1,124 @@ +"""Example: build an eCAL estimate from its individual pipeline stages. + +`ecal.estimate()` (see examples/basic_estimate.py) bundles all of this into +one call. This script instead drives each stage directly through the +underlying classes, which is useful when you need more control than +`estimate()` exposes -- e.g. a custom multi-hop transmission path, or +reusing one FLOP calculator across several estimates. + +Pipeline modeled here: an edge device collects samples, sends them over two +network hops (WiFi -> Ethernet) to a server, which preprocesses, trains, and +serves inference -- then ships inference results back over the same path. + +Run with: + python examples/full_pipeline.py +""" + +from ecal.calculators.inference import Inference +from ecal.calculators.model_flops import TransformerCalculator +from ecal.calculators.preprocessing import DataPreprocessing +from ecal.calculators.training import Training +from ecal.calculators.transmission import Transmission +from ecal.hardware.profiles import get_profile + +# --- Configuration ------------------------------------------------------- + +hardware = get_profile("nvidia_h100_sxm") +flops_per_second = hardware.flops_per_second_fp32 +processor_power = hardware.tdp_watts + +num_train_samples = 5000 +num_inferences = 20000 +sample_size = 32 # sequence length, used as the model's input dimension +float_precision = 32 # bits per value transmitted/processed + +# A small decoder-only Transformer; reused for both training and inference +# so its FLOP profile is only computed once per input shape. +calculator = TransformerCalculator( + context_length=sample_size, + embedding_size=64, + num_heads=4, + num_decoder_blocks=4, + feed_forward_size=128, + vocab_size=5000, +) +input_size = (1, sample_size) + +# --- 1. Transmission: raw samples travel edge device -> gateway -> server, +# each hop with its own protocol stack. --------------------------- + +edge_to_gateway = Transmission(datalink="WIFI_MAC", physical="WIFI_PHY", failure_rate=0.01) +gateway_to_server = Transmission(datalink="ETHERNET", physical="Generic_physical", failure_rate=0.0) + +train_bits = float_precision * sample_size * num_train_samples +hop1 = edge_to_gateway.calculate_energy(train_bits) +hop2 = gateway_to_server.calculate_energy(hop1["total_bits"]) +transmission_energy = hop1["total_energy"] + hop2["total_energy"] + +# --- 2. Preprocessing: normalize the data once it reaches the server. ---- + +preprocessing = DataPreprocessing( + preprocessing_type="normalization", + processor_flops_per_second=flops_per_second, + processor_max_power=processor_power, +) +preprocessing_result = preprocessing.calculate_energy(num_train_samples, sample_size) +preprocessing_energy = preprocessing_result["total_energy"] + +# --- 3. Training: 5-fold cross-validation over the preprocessed data. ---- + +training = Training( + model_name="Transformer", + batch_size=32, + num_epochs=20, + num_samples=num_train_samples, + processor_flops_per_second=flops_per_second, + processor_max_power=processor_power, + input_size=input_size, + evaluation_strategy="cross_validation", + k_folds=5, + split_ratio=0.8, + calculator=calculator, +) +training_result = training.calculate_energy() + +# --- 4. Inference: serve num_inferences requests with the trained model. - + +inference = Inference( + model_name="Transformer", + input_size=input_size, + num_samples=num_inferences, + processor_flops_per_second=flops_per_second, + processor_max_power=processor_power, + calculator=calculator, +) +inference_energy = inference.calculate_energy() + +# --- 5. Ship inference results back over the same two-hop network. ------ + +inference_bits = float_precision * sample_size * num_inferences +inf_hop1 = edge_to_gateway.calculate_energy(inference_bits) +inf_hop2 = gateway_to_server.calculate_energy(inf_hop1["total_bits"]) +inference_transmission_energy = inf_hop1["total_energy"] + inf_hop2["total_energy"] + +# --- Summary -------------------------------------------------------------- + +total_energy = ( + transmission_energy + + preprocessing_energy + + training_result["total_energy"] + + inference_energy + + inference_transmission_energy +) +total_bits = train_bits + inference_bits +ecal_j_per_bit = total_energy / total_bits + +print("Energy breakdown (Joules):") +print(f" transmission (data upload) : {transmission_energy:.6f} J") +print(f" preprocessing : {preprocessing_energy:.6f} J") +print(f" training : {training_result['training_energy']:.6f} J") +print(f" evaluation : {training_result['evaluation_energy']:.6f} J") +print(f" inference : {inference_energy:.6f} J") +print(f" transmission (results) : {inference_transmission_energy:.6f} J") +print(f" {'total':27s}: {total_energy:.6f} J") +print(f"\neCAL metric: {ecal_j_per_bit:.3e} J/bit") diff --git a/notebooks/allTrainingEnergy.py b/notebooks/allTrainingEnergy.py index 1faa147..9c4dc79 100644 --- a/notebooks/allTrainingEnergy.py +++ b/notebooks/allTrainingEnergy.py @@ -106,25 +106,7 @@ def theoretical_energy_proportionality(utilization, P_idle, P_peak): return power_consumption -class SimpleMLP(nn.Module): - def __init__(self, input_size=10, hidden_size=10, output_size=2, num_layers=3): - super(SimpleMLP, self).__init__() - self.layers = nn.ModuleList() - # Input layer - self.layers.append(nn.Linear(input_size, hidden_size)) - self.layers.append(nn.ReLU()) - # Hidden layers - for _ in range(num_layers - 2): # Adjusted loop for clarity - self.layers.append(nn.Linear(hidden_size, hidden_size)) - self.layers.append(nn.ReLU()) - # Output layer - Add it to the list! - self.layers.append(nn.Linear(hidden_size, output_size)) - - def forward(self, x): - # Simpler forward pass that processes all layers sequentially - for layer in self.layers: - x = layer(x) - return x +from calculators.ToyModels import SimpleMLP_practical as SimpleMLP # noqa: E402 # --- CNN Model (Modified to vary dense layers) --- diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..664abc8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,84 @@ +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "ecal-energy" +version = "0.1.0" +description = "Analytical estimation of the energy cost of the AI lifecycle (J/bit)" +readme = "README.md" +license = "BSD-3-Clause" +requires-python = ">=3.9" +authors = [ + {name = "SensorLab", email = "sensorlab.jsi@gmail.com"}, +] +keywords = ["energy", "AI", "lifecycle", "sustainability", "eCAL"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Science/Research", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] +dependencies = [ + "torch>=2.0", + "torchvision>=0.15", + "calflops>=0.3", + "transformers>=4.30", + "numpy>=1.24", + "pyyaml>=6.0", +] + +[project.optional-dependencies] +full = [ + "pandas", + "matplotlib", + "scikit-learn", + "codecarbon", + "efficient-kan", +] +dev = [ + "pytest>=7.0", + "ruff>=0.4", + "mypy>=1.0", +] +docs = [ + "sphinx>=7.0", + "furo>=2024.1.29", + "myst-parser>=2.0", + "sphinx-autobuild>=2024.4.16", +] + +[project.scripts] +ecal = "ecal.cli:main" + +[project.urls] +Homepage = "https://github.com/sensorlab/eCAL" +Documentation = "https://sensorlab.github.io/eCAL/docs/" +Paper = "https://ieeexplore.ieee.org/abstract/document/11298182" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +"ecal.hardware" = ["data/*.yaml"] + +[tool.ruff] +target-version = "py39" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-v" + +[tool.mypy] +python_version = "3.9" +warn_return_any = true +warn_unused_configs = true +ignore_missing_imports = true diff --git a/requirements.txt b/requirements.txt index 7656aa9..c7100c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,10 @@ -calflops==0.3.2 -torch==2.5.1 -torchvision==0.20.1 -transformers==4.48.1 +calflops>=0.3.2 +torch>=2.0 +torchvision>=0.15 +transformers>=4.30 +numpy>=1.24 +pyyaml>=6.0 +pandas +matplotlib +scikit-learn +codecarbon diff --git a/scripts/All_Theoretical_Empirical_Training_Energy.py b/scripts/All_Theoretical_Empirical_Training_Energy.py index ca3fe1c..c92e0e5 100644 --- a/scripts/All_Theoretical_Empirical_Training_Energy.py +++ b/scripts/All_Theoretical_Empirical_Training_Energy.py @@ -115,25 +115,7 @@ def theoretical_energy_proportionality(utilization, P_idle, P_peak): return power_consumption -class SimpleMLP(nn.Module): - def __init__(self, input_size=10, hidden_size=10, output_size=2, num_layers=3): - super(SimpleMLP, self).__init__() - self.layers = nn.ModuleList() - # Input layer - self.layers.append(nn.Linear(input_size, hidden_size)) - self.layers.append(nn.ReLU()) - # Hidden layers - for _ in range(num_layers - 2): # Adjusted loop for clarity - self.layers.append(nn.Linear(hidden_size, hidden_size)) - self.layers.append(nn.ReLU()) - # Output layer - Add it to the list! - self.layers.append(nn.Linear(hidden_size, output_size)) - - def forward(self, x): - # Simpler forward pass that processes all layers sequentially - for layer in self.layers: - x = layer(x) - return x +from calculators.ToyModels import SimpleMLP_practical as SimpleMLP # noqa: E402 # --- CNN Model (Modified to vary dense layers) --- diff --git a/src/ecal/__init__.py b/src/ecal/__init__.py new file mode 100644 index 0000000..5ac8376 --- /dev/null +++ b/src/ecal/__init__.py @@ -0,0 +1,6 @@ +"""eCAL: Analytical estimation of the energy cost of the AI lifecycle (J/bit).""" + +from ecal._version import __version__ +from ecal.api import estimate + +__all__ = ["estimate", "__version__"] diff --git a/src/ecal/_version.py b/src/ecal/_version.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/src/ecal/_version.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/src/ecal/api.py b/src/ecal/api.py new file mode 100644 index 0000000..6d3b993 --- /dev/null +++ b/src/ecal/api.py @@ -0,0 +1,250 @@ +"""Public API for eCAL energy estimation.""" + +import logging +from typing import Any, Dict, Optional + +from ecal.calculators.transmission import Transmission +from ecal.calculators.preprocessing import DataPreprocessing +from ecal.calculators.training import Training +from ecal.calculators.inference import Inference +from ecal.calculators.model_flops import ( + MLPCalculator, + CNNCalculator, + KANCalculator, + TransformerCalculator, +) +from ecal.hardware.profiles import get_profile + +logger = logging.getLogger(__name__) + +# Default protocol stack (generic for all layers) +_DEFAULT_PROTOCOLS = { + "application": "Generic_application", + "presentation": "Generic_presentation", + "session": "Generic_session", + "transport": "Generic_transport", + "network": "Generic_network", + "datalink": "Generic_datalink", + "physical": "Generic_physical", +} + + +def estimate( + model_type: str, + model_params: Optional[Dict[str, Any]] = None, + num_samples: int = 1000, + sample_size: int = 10, + num_epochs: int = 50, + batch_size: int = 64, + num_inferences: int = 10000, + float_precision: int = 64, + hardware: Optional[str] = None, + processor_flops_per_second: Optional[float] = None, + processor_max_power: Optional[float] = None, + preprocessing_type: str = "normalization", + evaluation_strategy: str = "cross_validation", + k_folds: int = 5, + split_ratio: float = 0.8, + transmission_hops: Optional[list] = None, + virtualization_overhead: float = 0.0, +) -> Dict[str, Any]: + """Estimate the total energy cost of an AI model lifecycle. + + Args: + model_type: Model architecture ("MLP", "CNN", "KAN", "Transformer") + model_params: Architecture-specific parameters. Keys depend on model_type: + MLP needs num_layers, din, dout; CNN needs num_cnv_layers, + num_pool_layers, i_r, i_c, k_r, k_c, c_in; KAN needs num_layers, + grid_size, din, dout; Transformer needs context_length, + embedding_size, num_heads, num_decoder_blocks, feed_forward_size, + vocab_size. + num_samples: Number of training samples + sample_size: Size of each sample (e.g., number of features) + num_epochs: Number of training epochs + batch_size: Training batch size + num_inferences: Number of inference runs + float_precision: Bits per float (default 64) + hardware: Hardware profile name (e.g., "generic_cpu", "apple_m2") + processor_flops_per_second: Override FLOPS (used if hardware is None) + processor_max_power: Override power in watts (used if hardware is None) + preprocessing_type: "normalization", "min_max_scaling", or "GADF" + evaluation_strategy: "cross_validation" or "train_test_split" + k_folds: Number of folds for cross-validation + split_ratio: Train/test split ratio + transmission_hops: List of dicts with protocol/failure_rate config per hop. + Each dict may contain keys: application, presentation, session, + transport, network, datalink, physical, failure_rate. + Defaults to a single hop with generic protocols and 0% failure. + virtualization_overhead: Energy overhead fraction [0, 1] + + Returns: + Dictionary with energy breakdown (Joules) and eCAL metric (J/bit). + Keys: "transmission", "preprocessing", "training", "evaluation", + "inference", "inference_process", "total" (all in Joules), + "ecal_j_per_bit" (J/bit), and bit counts "Ed bits", "inf_proc_bits", + "total_bits". + """ + if model_params is None: + model_params = {} + + # Resolve hardware + if hardware is not None: + profile = get_profile(hardware) + flops_ps = profile.flops_per_second_fp32 + max_power = profile.tdp_watts + else: + flops_ps = processor_flops_per_second or 1e13 + max_power = processor_max_power or 100 + + # Build FLOP calculator + calculator = _build_calculator(model_type, model_params, sample_size) + input_size = _get_input_size(model_type, sample_size) + + # Transmission + if transmission_hops is None: + transmission_hops = [{"failure_rate": 0.0}] + + data_bits = num_samples * float_precision * sample_size + transmission_energy = 0.0 + last_transmission = None + for hop_cfg in transmission_hops: + hop_protocols = {k: hop_cfg.get(k, v) for k, v in _DEFAULT_PROTOCOLS.items()} + transmission = Transmission( + failure_rate=hop_cfg.get("failure_rate", 0.0), + **hop_protocols, + ) + result = transmission.calculate_energy(data_bits) + transmission_energy += result["total_energy"] + last_transmission = transmission + + # Preprocessing + dp_flops_ps = flops_ps if hardware else (processor_flops_per_second or 1e10) + dp_max_power = max_power if hardware else (processor_max_power or 100) + preprocessing = DataPreprocessing( + preprocessing_type=preprocessing_type, + processor_flops_per_second=dp_flops_ps, + processor_max_power=dp_max_power, + time_steps=sample_size, + ) + preprocessing_calc = preprocessing.calculate_energy(num_samples, sample_size) + preprocessing_energy = preprocessing_calc["total_energy"] + + # Training + training = Training( + model_name=model_type, + num_epochs=num_epochs, + batch_size=batch_size, + processor_flops_per_second=flops_ps, + processor_max_power=max_power, + num_samples=num_samples, + input_size=input_size, + evaluation_strategy=evaluation_strategy, + k_folds=k_folds, + split_ratio=split_ratio, + calculator=calculator, + ) + training_calc = training.calculate_energy() + training_energy = training_calc["training_energy"] + evaluation_energy = training_calc["evaluation_energy"] + + # Inference + inference = Inference( + model_name=model_type, + input_size=input_size, + num_samples=num_inferences, + processor_flops_per_second=flops_ps, + processor_max_power=max_power, + calculator=calculator, + ) + inference_energy = inference.calculate_energy() + + # Inference transmission + preprocessing + inference_transmission_energy = 0.0 + if last_transmission is not None: + inf_bits = num_inferences * float_precision * sample_size + inf_tx = last_transmission.calculate_energy(inf_bits) + inference_transmission_energy = inf_tx["total_energy"] + + inference_preprocessing_calc = preprocessing.calculate_energy(num_inferences, sample_size) + inference_preprocessing_energy = inference_preprocessing_calc["total_energy"] + + inference_process = inference_energy + inference_transmission_energy + inference_preprocessing_energy + + # Total + total_energy = ( + transmission_energy + + preprocessing_energy + + training_energy + + evaluation_energy + + inference_process + ) + total_energy *= 1 + virtualization_overhead + + ed_bits = float_precision * sample_size * num_samples + inf_proc_bits = float_precision * sample_size * num_inferences + total_bits = ed_bits + inf_proc_bits + ecal_j_per_bit = total_energy / total_bits if total_bits > 0 else 0.0 + + return { + "transmission": transmission_energy, + "preprocessing": preprocessing_energy, + "training": training_energy, + "evaluation": evaluation_energy, + "inference": inference_energy, + "inference_process": inference_process, + "total": total_energy, + "ecal_j_per_bit": ecal_j_per_bit, + "Ed bits": ed_bits, + "inf_proc_bits": inf_proc_bits, + "total_bits": total_bits, + } + + +def _build_calculator(model_type: str, model_params: dict, sample_size: int): + """Build a FLOP calculator for the given model type.""" + mt = model_type.upper() + if mt == "MLP": + return MLPCalculator( + num_layers=model_params.get("num_layers", 3), + din=model_params.get("din", 10), + dout=model_params.get("dout", 2), + num_samples=sample_size, + num_classes=model_params.get("num_classes", 2), + ) + elif mt == "CNN": + return CNNCalculator( + num_cnv_layers=model_params.get("num_cnv_layers", 3), + num_pool_layers=model_params.get("num_pool_layers", 3), + i_r=model_params.get("i_r", 10), + i_c=model_params.get("i_c", 1), + k_r=model_params.get("k_r", 3), + k_c=model_params.get("k_c", 1), + c_in=model_params.get("c_in", 1), + num_samples=sample_size, + num_classes=model_params.get("num_classes", 2), + ) + elif mt == "KAN": + return KANCalculator( + num_layers=model_params.get("num_layers", 3), + grid_size=model_params.get("grid_size", 10), + din=model_params.get("din", 10), + dout=model_params.get("dout", 2), + num_samples=sample_size, + num_classes=model_params.get("num_classes", 2), + ) + elif mt == "TRANSFORMER": + return TransformerCalculator( + context_length=model_params.get("context_length", 10), + embedding_size=model_params.get("embedding_size", 16), + num_heads=model_params.get("num_heads", 2), + num_decoder_blocks=model_params.get("num_decoder_blocks", 3), + feed_forward_size=model_params.get("feed_forward_size", 32), + vocab_size=model_params.get("vocab_size", 2), + ) + else: + raise ValueError(f"Unsupported model type: {model_type}. Use MLP, CNN, KAN, or Transformer.") + + +def _get_input_size(model_type: str, sample_size: int): + """Get the default input size tuple for a model type.""" + return (1, sample_size) diff --git a/src/ecal/calculators/__init__.py b/src/ecal/calculators/__init__.py new file mode 100644 index 0000000..0b5ecd3 --- /dev/null +++ b/src/ecal/calculators/__init__.py @@ -0,0 +1,41 @@ +"""eCAL calculator modules for energy estimation across the AI lifecycle.""" + +from ecal.calculators.transmission import Transmission +from ecal.calculators.preprocessing import DataPreprocessing +from ecal.calculators.training import Training +from ecal.calculators.inference import Inference +from ecal.calculators.model_flops import ( + FLOPCalculator, + FlopsCalculatorFactory, + CalFlopsCalculatorHF, + CalFlopsCalculatorPT, + MLPCalculator, + CNNCalculator, + KANCalculator, + TransformerCalculator, +) +from ecal.calculators.preprocessing_flops import ( + PreprocessingFLOPCalculator, + NormalizationCalculator, + MinMaxScalingCalculator, + GramianDifferenceFieldCalculator, +) + +__all__ = [ + "Transmission", + "DataPreprocessing", + "Training", + "Inference", + "FLOPCalculator", + "FlopsCalculatorFactory", + "CalFlopsCalculatorHF", + "CalFlopsCalculatorPT", + "MLPCalculator", + "CNNCalculator", + "KANCalculator", + "TransformerCalculator", + "PreprocessingFLOPCalculator", + "NormalizationCalculator", + "MinMaxScalingCalculator", + "GramianDifferenceFieldCalculator", +] diff --git a/src/ecal/calculators/inference.py b/src/ecal/calculators/inference.py new file mode 100644 index 0000000..bd1afa8 --- /dev/null +++ b/src/ecal/calculators/inference.py @@ -0,0 +1,63 @@ +from typing import Tuple, Optional +from ecal.calculators.model_flops import FLOPCalculator, FlopsCalculatorFactory +from torchvision.models import resnet18 + + +class Inference: + """ + This class is used to estimate the flops of the model inference, which is then used to estimate + the energy consumption of the model inference. + """ + + def __init__(self, model_name: str, input_size: Tuple, num_samples: int, processor_flops_per_second: float, + processor_max_power: int, calculator: Optional[FLOPCalculator] = None): + """ + Initialize Inference class + Args: + calculator: FLOPCalculator implementation + model_name: PyTorch model or model name + input_size: Tuple of input size + num_samples: int of number of samples + processor_flops_per_second: float of processor flops per second + processor_max_power: int of processor max power in watts + """ + if model_name == 'resnet18': + self.model = resnet18() + else: + self.model = model_name + + if calculator is not None: + self.calculator = calculator + else: + self.calculator = FlopsCalculatorFactory.create_calculator(self.model) + self.input_size = input_size + self.num_samples = num_samples + # hardware parameters + self.processor_flops_per_second = processor_flops_per_second + self.processor_max_power = processor_max_power + + def calculate_flops(self) -> float: + """ + Calculate total FLOPs for the current inference workload + + Returns: + Total FLOPs across num_samples inference calls (a single forward + pass's FLOPs multiplied by num_samples) + """ + forward_flops = self.calculator.calculate(self.model, self.input_size)['total_flops'] + total_flops = forward_flops * self.num_samples + return total_flops + + def calculate_energy(self) -> float: + """ + Calculate the energy usage of the current inference + + Returns: + Total energy usage of the current inference in Joules + """ + # Calculate the total number of flops + total_flops = self.calculate_flops() + # Calculate the total energy usage + total_time = total_flops / self.processor_flops_per_second + total_energy = total_time * self.processor_max_power + return total_energy diff --git a/src/ecal/calculators/model_flops.py b/src/ecal/calculators/model_flops.py new file mode 100644 index 0000000..7fa6ce5 --- /dev/null +++ b/src/ecal/calculators/model_flops.py @@ -0,0 +1,409 @@ +"""FLOP calculators for supported model architectures (MLP, CNN, KAN, Transformer) +and thin wrappers around the third-party ``calflops`` library for arbitrary +PyTorch/HuggingFace models.""" + +from abc import ABC, abstractmethod +from typing import Dict, Union, Tuple + +from calflops import calculate_flops_hf, calculate_flops +from torch import nn + + +class FlopsCalculatorFactory: + """Factory that selects a :class:`FLOPCalculator` implementation based on + whether the model is a HuggingFace model name (str) or a ``torch.nn.Module``. + """ + + @staticmethod + def create_calculator(model: Union[nn.Module, str]) -> 'FLOPCalculator': + """Create a FLOP calculator appropriate for the given model. + + Args: + model: A HuggingFace model name (str) or an instantiated + ``torch.nn.Module``. + + Returns: + FLOPCalculator: A ``CalFlopsCalculatorHF`` for string model names, + or a ``CalFlopsCalculatorPT`` for ``nn.Module`` instances. + + Raises: + ValueError: If ``model`` is neither a string nor an ``nn.Module``. + """ + if isinstance(model, str): + return CalFlopsCalculatorHF() + elif isinstance(model, nn.Module): + return CalFlopsCalculatorPT() + else: + raise ValueError("Model must be either a string (HuggingFace model name) or nn.Module (PyTorch model)") + + +class FLOPCalculator(ABC): + """Abstract base class for FLOP calculators. + + Subclasses implement :meth:`calculate` to estimate the floating-point + operations required for a single forward pass of a model. + """ + + @abstractmethod + def calculate(self, model: Union[nn.Module, str], input_size: Tuple) -> Dict[str, Union[int, Dict]]: + """Calculate FLOPs (and, where available, parameter count) for a model. + + Args: + model: The model to analyze (``nn.Module`` or HuggingFace model + name, depending on the concrete implementation). + input_size: Shape of a single input sample, e.g. ``(1, sample_size)``. + + Returns: + Dict[str, Union[int, Dict]]: At minimum a ``"total_flops"`` key; + implementations may also include ``"total_params"`` and a + ``"breakdown"`` of FLOPs by component. + """ + pass + + +class CalFlopsCalculatorHF(FLOPCalculator): + """FLOP calculator for HuggingFace models, backed by ``calflops.calculate_flops_hf``.""" + + def calculate(self, model: str, input_size: Tuple) -> Dict[str, Union[int, Dict]]: + """Calculate FLOPs for a HuggingFace model by name. + + Args: + model: HuggingFace model identifier (e.g. ``"bert-base-uncased"``). + input_size: Input shape passed to ``calculate_flops_hf``. + + Returns: + Dict[str, Union[int, Dict]]: ``"total_flops"`` and ``"total_params"``. + """ + flops, macs, params = calculate_flops_hf(model_name=model, input_shape=input_size, print_results=False, + output_as_string=False) + return {"total_flops": flops, "total_params": params} + + +class CalFlopsCalculatorPT(FLOPCalculator): + """FLOP calculator for PyTorch ``nn.Module`` models, backed by ``calflops.calculate_flops``.""" + + def calculate(self, model: nn.Module, input_size: Tuple) -> Dict[str, Union[int, Dict]]: + """Calculate FLOPs for a PyTorch model. + + Args: + model: A ``torch.nn.Module`` instance. + input_size: Input shape passed to ``calculate_flops``. + + Returns: + Dict[str, Union[int, Dict]]: ``"total_flops"`` and ``"total_params"``. + """ + flops, macs, params = calculate_flops(model=model, + input_shape=input_size, + output_as_string=False, + output_precision=4, + print_results=False) + return {"total_flops": flops, "total_params": params} + +class MLPCalculator(FLOPCalculator): + """Analytical FLOP calculator for a Multilayer Perceptron (MLP).""" + + def __init__(self, num_layers: int, din: int, dout: int, num_samples: int = 1, + num_classes: int = 2): + """Initialize the MLP calculator. + + Args: + num_layers: Number of layers ``L`` in the network. + din: Input dimension of the network. + dout: Output dimension of the network. + num_samples: Number of samples the FLOP count will later be scaled by. + num_classes: Number of output classes. + """ + self.din = din + self.dout = dout + self.L = num_layers + self.T = num_samples + self.C = num_classes + + def calculate(self, model: nn.Module, input_size: Tuple) -> Dict[str, Union[int, Dict]]: + """Calculate FLOPs and parameters for a Multilayer Perceptron. + + Args: + model: Unused; present to satisfy the :class:`FLOPCalculator` interface. + input_size: Unused; present to satisfy the :class:`FLOPCalculator` interface. + + Notes: + L: Number of layers. + M_l-1: Input dimension of the layer. + M_l: Output dimension of the layer. + + Returns: + Dict[str, Union[int, Dict]]: ``"total_flops"`` (int) and + ``"total_params"`` (``None``, not computed by this calculator). + """ + L = self.L # Number of layers + + # Total FLOPs calculation following the new formula + total_flops = 0 + for l in range(0, L - 1): + + # FLOPs from input-output dimension computation + layer_flops = 2 * (self.din * self.din) + 2 * self.din + + total_flops += layer_flops + + return { + 'total_flops': int(total_flops), + 'total_params': None + } + +class CNNCalculator(FLOPCalculator): + """Analytical FLOP calculator for a Convolutional Neural Network (CNN).""" + + def __init__(self, num_cnv_layers: int = 3, num_pool_layers: int = 1, i_r: int = 10, i_c: int = 1, + k_r: int = 3, k_c: int = 1, c_in: int = 1, s_r: int = 1, s_c: int = 1, N_f: int = 3, num_samples: int = 1, + num_classes: int = 2): + """Initialize the CNN calculator. + + Args: + num_cnv_layers: Number of convolutional layers. + num_pool_layers: Number of pooling layers. + i_r: Input height. + i_c: Input width. + k_r: Kernel height. + k_c: Kernel width. + c_in: Number of input channels. + s_r: Stride along height. + s_c: Stride along width. + N_f: Number of filters. + num_samples: Number of samples the FLOP count will later be scaled by. + num_classes: Number of output classes. + """ + self.num_cnv_layers = num_cnv_layers + self.num_pool_layers = num_pool_layers + self.i_r = i_r + self.i_c = i_c + self.k_r = k_r + self.k_c = k_c + self.p_r = k_r - 1 + self.p_c = k_c + self.c_in = c_in + self.s_r = s_r + self.s_c = s_c + self.N_f = N_f + self.T = num_samples + self.C = num_classes + + def calculate(self, model: nn.Module, input_size: Tuple) -> Dict[str, Union[int, Dict]]: + """Calculate FLOPs and parameters for a Convolutional Neural Network. + + Args: + model: Unused; present to satisfy the :class:`FLOPCalculator` interface. + input_size: Unused; present to satisfy the :class:`FLOPCalculator` interface. + + Notes: + num_cnv_layers: Number of convolutional layers. + num_pool_layers: Number of pooling layers. + + Returns: + Dict[str, Union[int, Dict]]: ``"total_flops"`` (int) and + ``"total_params"`` (``None``, not computed by this calculator). + """ + num_cnv_layers = self.num_cnv_layers # Number of layers + num_pool_layers = self.num_pool_layers # Number of layers + + input_height = self.i_r + input_width = self.i_c + + output_height = 0 + output_width = 0 + # Total FLOPs calculation following the new formula + total_flops = 0 + for c in range(0, num_cnv_layers - 1): + + # FLOPs from convolutional layers + output_height = (input_height - self.k_r + 2 * self.p_r) / self.s_r + 1 + output_width = (input_width - self.k_c + 2 * self.p_c) / self.s_c + 1 + + # Create convolutional blocks with increasing channel depth + input_height = self.i_r * (2 ** c) + input_width = self.i_c * (2 ** c) + + layer_flops = output_height * output_width * (self.c_in * self.k_r * self.k_c + 1) * self.N_f + + total_flops += layer_flops + + for p in range(0, num_pool_layers): + + # FLOPs from pooling layers + output_height = (input_height - self.k_r + 2 * self.p_r) / self.s_r + 1 + output_width = (input_width - self.k_c + 2 * self.p_c) / self.s_c + 1 + + layer_flops = output_height * output_width * self.c_in + + total_flops += layer_flops + + # add final layer + total_flops += 2 * (output_height * output_width) + 2 * output_height + + return { + 'total_flops': int(total_flops), + 'total_params': None + } + + +class KANCalculator(FLOPCalculator): + """Analytical FLOP calculator for a Kolmogorov-Arnold Network (KAN).""" + + def __init__(self, num_layers: int, grid_size: int, din: int, dout: int, k: int = 3, num_samples: int = 1, + num_classes: int = 2): + """Initialize the KAN calculator. + + Args: + num_layers: Number of layers ``L``. + grid_size: B-spline grid size ``G``. + din: Input dimension of the network. + dout: Output dimension of the network. + k: B-spline degree (default 3). + num_samples: Number of samples the FLOP count will later be scaled by. + num_classes: Number of output classes. + """ + self.G = grid_size + self.din = din + self.dout = dout + self.k = k + self.L = num_layers + self.T = num_samples + self.C = num_classes + + def calculate(self, model: nn.Module, input_size: Tuple) -> Dict[str, Union[int, Dict]]: + """Calculate FLOPs and parameters for a Kolmogorov-Arnold Network (KAN). + + Args: + model: Unused; present to satisfy the :class:`FLOPCalculator` interface. + input_size: Unused; present to satisfy the :class:`FLOPCalculator` interface. + + Notes: + K: B-spline degree (typically 3). + G: Grid size. + L: Number of layers. + M_l-1: Input dimension of the layer. + M_l: Output dimension of the layer. + M_NLF: FLOPs for the non-linear function (B-spline activation). + + Returns: + Dict[str, Union[int, Dict]]: ``"total_flops"`` (int) and + ``"total_params"`` (``None``, not computed by this calculator). + """ + K = self.k # B-spline degree + G = self.G # Grid size + L = self.L # Number of layers + + # Constant for B-spline and grid computation + M_B = 9 * K * (G + 1.5 * K) + 2 * G - 2.5 * K + 3 + + # Assuming M_NLF is the FLOPs for B-spline activation function + # This might need to be precisely defined based on the specific implementation + M_NLF = 2 # Placeholder, adjust based on actual B-spline activation computation + + # Total FLOPs calculation following the new formula + total_flops = 0 + for l in range(1, L): + # FLOPs from B-spline activation + b_spline_flops = M_NLF * self.din + + # FLOPs from input-output dimension computation with B-spline transformation + layer_flops = (self.din * self.din) * M_B + + total_flops += b_spline_flops + layer_flops + + return { + 'total_flops': int(total_flops), + 'total_params': None + } + + +class TransformerCalculator(FLOPCalculator): + """Analytical FLOP calculator for a decoder-only Transformer model.""" + + def __init__(self, context_length: int, embedding_size: int, num_heads: int, + num_decoder_blocks: int, feed_forward_size: int, vocab_size: int): + """Initialize the Transformer calculator. + + Args: + context_length: Sequence/context length ``C``. + embedding_size: Embedding dimension ``N_embed``. + num_heads: Number of attention heads ``N_head``. + num_decoder_blocks: Number of decoder blocks ``N_decoder_blocks``. + feed_forward_size: Feed-forward layer width ``FFS``. + vocab_size: Vocabulary size. + """ + self.context_length = context_length + self.embedding_size = embedding_size + self.num_heads = num_heads + self.num_decoder_blocks = num_decoder_blocks + self.feed_forward_size = feed_forward_size + self.vocab_size = vocab_size + + def calculate(self, model: nn.Module, input_size: Tuple) -> Dict[str, Union[int, Dict]]: + """Calculate FLOPs for a Transformer model. + + Args: + model: Unused; present to satisfy the :class:`FLOPCalculator` interface. + input_size: Unused; present to satisfy the :class:`FLOPCalculator` interface. + + Notes: + C: Context length. + N_embed: Embedding size. + N_head: Number of attention heads. + N_decoder_blocks: Number of decoder blocks. + FFS: Feed forward size. + + Returns: + Dict[str, Union[int, Dict]]: ``"total_flops"`` (int), ``"total_params"`` + (``None``), and a ``"breakdown"`` dict with per-component attention + FLOPs (``kqv_embedding_flops``, ``attention_score_flops``, + ``reduce_flops``, ``projection_flops``, ``total_attention_flops``), + ``mlp_blocks_flops``, and ``per_block_flops``. + """ + # Model parameters + C = self.context_length + N_embed = self.embedding_size + N_head = self.num_heads + N_decoder_blocks = self.num_decoder_blocks + FFS = self.feed_forward_size + + # Calculate attention FLOPs (M_ATT) + # K, Q, V positional embedding + kqv_flops = C * N_embed * 3 * N_embed + + # Attention scores + attention_score_flops = C * C * N_embed + + # Reduce operation + reduce_flops = N_head * C * C * (N_embed // N_head) + + # Projection + projection_flops = C * N_embed * N_embed + + # Total attention FLOPs (multiplied by 2 as per equation) + M_ATT = 2 * (kqv_flops + attention_score_flops + reduce_flops + projection_flops) + + # MLP blocks FLOPs + mlp_flops = 2 * 2 * C * N_embed * FFS + + # Total Transformer FLOPs (M_TR) + M_TR = N_decoder_blocks * (M_ATT + mlp_flops) + + total_flops = M_TR + + # Calculate parameters + + return { + 'total_flops': int(total_flops), + 'total_params': None, + 'breakdown': { + 'attention': { + 'kqv_embedding_flops': int(kqv_flops), + 'attention_score_flops': int(attention_score_flops), + 'reduce_flops': int(reduce_flops), + 'projection_flops': int(projection_flops), + 'total_attention_flops': int(M_ATT) + }, + 'mlp_blocks_flops': int(mlp_flops), + 'per_block_flops': int(M_ATT + mlp_flops), + }} diff --git a/src/ecal/calculators/preprocessing.py b/src/ecal/calculators/preprocessing.py new file mode 100644 index 0000000..94726a2 --- /dev/null +++ b/src/ecal/calculators/preprocessing.py @@ -0,0 +1,85 @@ +from typing import Dict + +from ecal.calculators.preprocessing_flops import ( + NormalizationCalculator, + MinMaxScalingCalculator, + GramianDifferenceFieldCalculator, +) + + +class DataPreprocessing: + """Data preprocessing class that calculates the FLOPs for various data preprocessing tasks""" + + def __init__(self, preprocessing_type: str = 'normalization', processor_flops_per_second: float = 1e12, + processor_max_power: int = 100, time_steps: int = 1): + """ + Initialize DataPreprocessing class + + Args: + preprocessing_type: Type of preprocessing to perform + ("normalization", "min_max_scaling", or "GADF"). + processor_flops_per_second: Processor throughput in FLOPS, used to + convert FLOP counts into elapsed time for energy estimation. + processor_max_power: Processor power draw in watts, used to convert + elapsed time into energy. + time_steps: Number of time steps per sample (only relevant for the + "GADF" preprocessing type). + """ + self.calculators = { + 'normalization': NormalizationCalculator(), + 'min_max_scaling': MinMaxScalingCalculator(), + 'GADF': GramianDifferenceFieldCalculator() + } + self.preprocessing_type = preprocessing_type + self.set_preprocessing_type(preprocessing_type) + self.processor_flops_per_second = processor_flops_per_second + self.processor_max_power = processor_max_power + + def set_preprocessing_type(self, preprocessing_type: str) -> None: + """Set the preprocessing type""" + if preprocessing_type not in self.calculators: + raise ValueError(f"Unsupported preprocessing type: {preprocessing_type}") + self.calculator = self.calculators[preprocessing_type] + + def calculate_flops(self, data_bits: int, time_steps=1) -> float: + """ + Calculate FLOPs for the current preprocessing type + + Args: + data_bits: Number of bits in the input data + time_steps: Number of time steps in the input time series data + + Returns: + Total FLOPs for the current preprocessing type + """ + if self.preprocessing_type == 'GADF': + return self.calculator.calculate_flops(data_bits, time_steps) + + return self.calculator.calculate_flops(data_bits) + + def calculate_energy(self, data_bits: int, time_steps: int) -> Dict[str, float]: + """ + Calculate the energy usage of the current preprocessing step + + Args: + data_bits: Number of scalar data points per sample + time_steps: Number of time steps per sample (only used when + preprocessing_type is "GADF") + + Returns: + Dictionary with "total_energy" (Joules) and "total_bits" + (data_bits * time_steps, the total number of scalar values processed) + """ + # Calculate the total number of flops + if self.preprocessing_type == 'GADF': + calc_dict = self.calculate_flops(data_bits, time_steps) + else: + calc_dict = self.calculate_flops(data_bits * time_steps) + total_flops = calc_dict['total_flops'] + + total_time = total_flops / self.processor_flops_per_second + total_energy = total_time * self.processor_max_power + return { + "total_energy": total_energy, + "total_bits": data_bits * time_steps, + } diff --git a/src/ecal/calculators/preprocessing_flops.py b/src/ecal/calculators/preprocessing_flops.py new file mode 100644 index 0000000..6869ad0 --- /dev/null +++ b/src/ecal/calculators/preprocessing_flops.py @@ -0,0 +1,128 @@ +"""FLOP calculators for data preprocessing steps (normalization, min-max +scaling, and Gramian Angular/Difference Field encoding).""" + +from abc import ABC, abstractmethod +from typing import Dict, Union + + +class PreprocessingFLOPCalculator(ABC): + """Abstract base class for data-preprocessing FLOP calculators.""" + + @abstractmethod + def calculate_flops(self, data_size: int) -> Dict[str, Union[int, Dict]]: + """Calculate FLOPs required to preprocess a batch of data. + + Args: + data_size: Total number of scalar data points to preprocess. + + Returns: + Dict[str, Union[int, Dict]]: ``"total_flops"`` and ``"data_shape"``. + """ + pass + + +class NormalizationCalculator(PreprocessingFLOPCalculator): + """FLOP calculator for z-score normalization (mean/std standardization).""" + + def calculate_flops(self, data_size: int) -> Dict[str, Union[int, Dict]]: + """Calculate FLOPs for z-score normalization of ``data_size`` points. + + Accounts for computing the mean, standard deviation, and applying + ``(x - mean) / std`` to every point. + + Args: + data_size: Total number of scalar data points to normalize. + + Returns: + Dict[str, Union[int, Dict]]: ``"total_flops"`` (``6 * data_size + 1``) + and ``"data_shape"`` (``None``, shape is unchanged by normalization). + """ + # calculating mean: + # 1. add all data points -> data_size - 1 + # 2. divide by data_size -> 1 + # Mean calculation FLOPS: data_size - 1 + 1 = data_size + # ------------------------------------------------------------ + # calculating std: + # 1. subtract mean from each data point -> data_size + # 2. square the result -> data_size + # 3. add the squares -> data_size - 1 + # 4. divide by data_size -> 1 + # 5. take the square root -> 1 + # Std. calculation FLOPS: data_size + data_size + (data_size - 1) + 1 + 1 = 3 * data_size + 1 + # ------------------------------------------------------------ + # normalization: + # 1. subtract mean from each data point -> data_size + # 2. divide by std -> data_size + # normalization FLOPS: data_size + data_size = 2 * data_size + # ------------------------------------------------------------ + + # FINAL total FLOPS calculation: data_size + 3 * data_size + 1 + 2 * data_size = 6 * data_size + 1 + + total_flops = (6 * data_size) + 1 + + return {"total_flops": total_flops, + "data_shape": None + } + + +class MinMaxScalingCalculator(PreprocessingFLOPCalculator): + """FLOP calculator for min-max scaling.""" + + def calculate_flops(self, data_size: int) -> Dict[str, Union[int, Dict]]: + """Calculate FLOPs for min-max scaling of ``data_size`` points. + + Accounts for computing ``max - min`` once and applying + ``(x - min) / (max - min)`` to every point. + + Args: + data_size: Total number of scalar data points to scale. + + Returns: + Dict[str, Union[int, Dict]]: ``"total_flops"`` (``2 * data_size + 1``) + and ``"data_shape"`` (``None``, shape is unchanged by scaling). + """ + # Min-Max scaling: + # 0. find max and min -> 0 + # 1. calculate max-min -> 1 + # 1. subtract min from each data point -> data_size + # 2. divide by (max - min) -> data_size + # Total FLOPS: 1 + data_size + data_size = 2 * data_size + 1 + + scaling_flops = data_size * 2 + 1 # + + return {"total_flops": scaling_flops, + "data_shape": None + } + + +class GramianDifferenceFieldCalculator(PreprocessingFLOPCalculator): + """FLOP calculator for Gramian Angular/Difference Field (GADF) encoding, + following the pyts implementation's FLOP profile.""" + + def calculate_flops(self, data_size: int, time_steps: int) -> Dict[str, Union[int, Dict]]: + """Calculate FLOPs for GADF encoding of time-series data. + + Accounts for two min-max scaling passes over the data followed by the + pairwise GADF computation across time steps. + + Args: + data_size: Number of independent time-series samples. + time_steps: Number of time steps per sample. + + Returns: + Dict[str, Union[int, Dict]]: ``"total_flops"`` and ``"data_shape"`` + (``(data_size, time_steps, time_steps)``, the shape of the resulting + GADF matrices). + """ + # + # 1. perform minmax 2 times -> 2 * data_size +1 + # 2. compute GADF flops based on pyTS implementation - > (5 * time_steps + time_steps * time_steps) * data_size + minmax_calculator = MinMaxScalingCalculator() + minmax_flops = minmax_calculator.calculate_flops(data_size * time_steps)["total_flops"] + + gadf_flops = (5 * time_steps + time_steps * time_steps) * data_size + total_flops = minmax_flops + gadf_flops + return { + "total_flops": total_flops, + "data_shape": (data_size, time_steps, time_steps) + } diff --git a/src/ecal/calculators/training.py b/src/ecal/calculators/training.py new file mode 100644 index 0000000..bbb2ffa --- /dev/null +++ b/src/ecal/calculators/training.py @@ -0,0 +1,142 @@ +from typing import Dict, Tuple, Optional + +from torchvision.models import resnet18 + +from ecal.calculators.model_flops import FLOPCalculator, FlopsCalculatorFactory + + +class Training: + """ + This class is used to estimate the flops of the model training, which is then used to estimate + the energy consumption of the model training. + """ + + def __init__(self, model_name: str, + batch_size: int, num_epochs: int, num_samples: int, + processor_flops_per_second: float, processor_max_power: int, input_size: Tuple, + evaluation_strategy: str, k_folds: int, split_ratio: float, + calculator: Optional[FLOPCalculator] = None): + """ + Initialize Training class with optional custom FLOP calculator + + Args: + calculator: Optional custom FLOPCalculator implementation + input_size: Tuple of input size + batch_size: int of batch size + num_epochs: int of number of epochs + num_samples: int of number of samples + processor_flops_per_second: float of processor flops per second + processor_max_power: int of processor max power in watts + evaluation_strategy: str of evaluation strategy + k_folds: int of number of folds for cross-validation + split_ratio: float of split ratio for train-test split + """ + if model_name == 'resnet18': + self.model = resnet18() + else: + self.model = model_name + + if calculator is not None: + self.calculator = calculator + else: + self.calculator = FlopsCalculatorFactory.create_calculator(self.model) + + if evaluation_strategy == 'train_test_split': + self.evaluation_strategy = 'train_test_split' + self.split_ratio = split_ratio + elif evaluation_strategy == 'cross_validation': + self.evaluation_strategy = 'cross_validation' + self.k_folds = k_folds + else: + raise ValueError(f"Unsupported evaluation strategy: {evaluation_strategy}") + + self.input_size = input_size + self.batch_size = batch_size + self.num_epochs = num_epochs + self.num_samples = num_samples + # hardware parameters + self.processor_flops_per_second = processor_flops_per_second + self.processor_max_power = processor_max_power + + def calculate_flops_training(self) -> float: + """ + Calculate total FLOPs required for training + + Approximates one training pass (forward + backward + weight update) + as three times the forward-pass FLOPs, then scales by the number of + training samples (per the configured evaluation strategy) and the + number of epochs. + + Returns: + Total training FLOPs across all epochs + """ + forward_flops = self.calculator.calculate(self.model, self.input_size)['total_flops'] + # 1 training pass takes roughly 3x a single forward pass + training_flops = forward_flops * 3 + # Calculate the number of batches + if self.evaluation_strategy == 'train_test_split': + training_samples = self.num_samples * self.split_ratio + elif self.evaluation_strategy == 'cross_validation': + percentage_of_samples = 1 - (1 / self.k_folds) # percentage of samples used for training + number_of_folds = self.k_folds + training_samples = self.num_samples * percentage_of_samples * number_of_folds + + else: + raise ValueError(f"Unsupported evaluation strategy: {self.evaluation_strategy}") + # Calculate the total number of flops + total_flops = training_flops * training_samples * self.num_epochs + return total_flops + + def calculate_flops_evaluation(self) -> float: + """ + Calculate total FLOPs required for evaluation + + Scales a single forward pass's FLOPs by the number of samples held + out for evaluation, based on the configured evaluation strategy + (train/test split or k-fold cross-validation). + + Returns: + Total evaluation FLOPs + """ + # Calculate the total number of flops + forward_flops = self.calculator.calculate(self.model, self.input_size)['total_flops'] + if self.evaluation_strategy == 'train_test_split': + evaluation_samples = self.num_samples * (1 - self.split_ratio) + elif self.evaluation_strategy == 'cross_validation': + percentage_of_samples = 1 / self.k_folds # percentage of samples used for evaluation + number_of_folds = self.k_folds + evaluation_samples = self.num_samples * percentage_of_samples * number_of_folds + else: + raise ValueError(f"Unsupported evaluation strategy: {self.evaluation_strategy}") + + total_flops = forward_flops * evaluation_samples + return total_flops + + def calculate_energy(self) -> Dict[str, float]: + """ + Calculate the total energy usage of training and evaluation + + Returns: + Dictionary with "total_energy" (training + evaluation, in Joules), + "training_energy", "evaluation_energy", "training_flops", + "evaluation_flops", "train_time" (seconds), and "eval_time" (seconds) + """ + # Calculate the total number of flops + training_flops = self.calculate_flops_training() + evaluation_flops = self.calculate_flops_evaluation() + + training_energy = training_flops / self.processor_flops_per_second * self.processor_max_power + evaluation_energy = evaluation_flops / self.processor_flops_per_second * self.processor_max_power + + # Calculate the total energy usage + total_energy = training_energy + evaluation_energy + + return { + "total_energy": total_energy, + "training_energy": training_energy, + "evaluation_energy": evaluation_energy, + "training_flops": training_flops, + "evaluation_flops": evaluation_flops, + "train_time": training_flops / self.processor_flops_per_second, + "eval_time": evaluation_flops / self.processor_flops_per_second + } diff --git a/src/ecal/calculators/transmission.py b/src/ecal/calculators/transmission.py new file mode 100644 index 0000000..aa8703c --- /dev/null +++ b/src/ecal/calculators/transmission.py @@ -0,0 +1,158 @@ +from typing import Dict, Union +from ecal.configs.protocol_configs import ( + LayerProtocol, + APPLICATION_PROTOCOLS, + PRESENTATION_PROTOCOLS, + SESSION_PROTOCOLS, + TRANSPORT_PROTOCOLS, + NETWORK_PROTOCOLS, + DATALINK_PROTOCOLS, + PHYSICAL_PROTOCOLS, +) + + +class Transmission: + """ + Simplified calculator for network energy consumption that allows protocol selection + for each OSI layer, focusing only on data and control plane overheads + """ + + def __init__(self, + application: str = 'HTTP', + presentation: str = 'TLS', + session: str = 'RPC', + transport: str = 'TCP', + network: str = 'IPv4', + datalink: str = 'WIFI_MAC', + physical: str = 'WIFI_PHY', + failure_rate: float = 0.0): + """ + Initialize calculator with specific protocols for each OSI layer + + Args: + application: Application-layer protocol name (key into + APPLICATION_PROTOCOLS, e.g. "HTTP", "FTP") + presentation: Presentation-layer protocol name (key into + PRESENTATION_PROTOCOLS, e.g. "TLS", "SSL") + session: Session-layer protocol name (key into SESSION_PROTOCOLS, + e.g. "RPC") + transport: Transport-layer protocol name (key into + TRANSPORT_PROTOCOLS, e.g. "TCP", "UDP") + network: Network-layer protocol name (key into NETWORK_PROTOCOLS, + e.g. "IPv4", "IPv6") + datalink: Data-link-layer protocol name (key into + DATALINK_PROTOCOLS, e.g. "ETHERNET", "WIFI_MAC") + physical: Physical-layer protocol name (key into + PHYSICAL_PROTOCOLS, e.g. "WIFI_PHY", "BLUETOOTH") + failure_rate: Probability of transmission failure (0.0 to 1.0) + + Raises: + KeyError: If a protocol name is not found in its layer's dictionary + ValueError: If failure_rate is not between 0 and 1 + """ + self.protocols = { + 'application': APPLICATION_PROTOCOLS[application], + 'presentation': PRESENTATION_PROTOCOLS[presentation], + 'session': SESSION_PROTOCOLS[session], + 'transport': TRANSPORT_PROTOCOLS[transport], + 'network': NETWORK_PROTOCOLS[network], + 'datalink': DATALINK_PROTOCOLS[datalink], + 'physical': PHYSICAL_PROTOCOLS[physical] + } + if not 0 <= failure_rate <= 1: + raise ValueError("Failure rate must be between 0 and 1") + self.failure_rate = failure_rate + + def calculate_layer_energy(self, protocol: LayerProtocol, input_bits: int) -> Dict[str, Union[float, int]]: + """Calculate energy consumption for a single OSI layer + + Args: + protocol: The protocol configuration for this layer + input_bits: Number of bits arriving at this layer from the layer above + + Returns: + Dictionary with "total_bits" (bits after adding this layer's + data/control-plane overhead), "total_energy" (Joules), and a + "breakdown" of the four energy terms (sender, receiver, IoT-node, + and gateway contributions) + """ + + # Calculate overhead bits + data_plane_bits = int(input_bits * protocol.data_plane_overhead) + control_plane_bits = int(input_bits * protocol.control_plane_overhead) + + # Total bits at this layer + total_bits = input_bits + data_plane_bits + control_plane_bits + first_term = total_bits * protocol.base_energy_per_bit_sender + second_term = total_bits * protocol.base_energy_per_bit_receiver + third_term = total_bits * protocol.Niot * protocol.Piot # Niot + fourth_term = total_bits * protocol.Ngateway * protocol.Pgateway # Ngateway + + total_energy = first_term + second_term + third_term + fourth_term + + return { + 'total_bits': total_bits, + 'total_energy': total_energy, + 'breakdown': { + 'first_term': first_term, + 'second_term': second_term, + 'third_term': third_term, + 'fourth_term': fourth_term + } + } + + def calculate_energy(self, data_bits: int) -> Dict[str, Union[float, Dict]]: + """Calculate energy consumption with retransmission consideration + + Args: + data_bits: Number of bits to transmit before protocol overhead + + Returns: + Dictionary with "total_energy" and "total_bits" scaled by the + expected number of transmissions (accounting for failure_rate via + a geometric-distribution expectation), "original_bits", + "expected_transmissions", "failure_rate", "single_transmission" + (the un-scaled result), and "layer_breakdown" (per-OSI-layer + energy detail) + """ + base_result = self._calculate_single_transmission(data_bits) + + # Calculate expected number of transmissions using geometric distribution + # E[X] = 1/(1-p) where p is failure rate + expected_transmissions = 1 / (1 - self.failure_rate) + + total_energy = base_result['total_energy'] * expected_transmissions + total_bits = base_result['total_bits'] * expected_transmissions + + return { + 'total_energy': total_energy, + 'total_bits': total_bits, + 'original_bits': data_bits, + 'expected_transmissions': expected_transmissions, + 'failure_rate': self.failure_rate, + 'single_transmission': base_result, + 'layer_breakdown': base_result['layer_breakdown'] + } + + def _calculate_single_transmission(self, data_bits: int) -> Dict[str, Union[float, Dict]]: + """Original calculation logic for a single transmission""" + current_bits = data_bits + total_energy = 0 + layer_results = {} + + for layer_name, protocol in self.protocols.items(): + curr_layer_result = self.calculate_layer_energy(protocol, current_bits) + layer_results[layer_name] = { + 'protocol': protocol.name, + 'energy': curr_layer_result['total_energy'], + 'breakdown': curr_layer_result['breakdown'] + } + total_energy += curr_layer_result['total_energy'] + current_bits = curr_layer_result['total_bits'] + + return { + 'total_energy': total_energy, + 'total_bits': current_bits, + 'original_bits': data_bits, + 'layer_breakdown': layer_results + } diff --git a/src/ecal/cli.py b/src/ecal/cli.py new file mode 100644 index 0000000..d90843d --- /dev/null +++ b/src/ecal/cli.py @@ -0,0 +1,156 @@ +"""Command-line interface for eCAL.""" + +import argparse +import json +import sys + +from ecal._version import __version__ + + +def main(argv=None): + """Entry point for the ``ecal`` console script. + + Parses command-line arguments and dispatches to the ``estimate`` or + ``profiles`` subcommand. With no subcommand, prints help and exits with + status 1. + + Args: + argv: Argument list to parse instead of ``sys.argv[1:]``. Primarily + useful for testing. + """ + parser = argparse.ArgumentParser( + prog="ecal", + description="eCAL: Estimate the energy cost of the AI lifecycle (J/bit)", + ) + parser.add_argument("--version", action="version", version=f"ecal {__version__}") + parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose output") + + subparsers = parser.add_subparsers(dest="command") + + # --- estimate subcommand --- + est = subparsers.add_parser("estimate", help="Estimate energy for a model") + est.add_argument("--model", required=True, choices=["MLP", "CNN", "KAN", "Transformer"], + help="Model architecture") + est.add_argument("--layers", type=int, default=3, help="Number of layers (default: 3)") + est.add_argument("--din", type=int, default=10, help="Input dimension (default: 10)") + est.add_argument("--dout", type=int, default=2, help="Output dimension (default: 2)") + est.add_argument("--epochs", type=int, default=50, help="Training epochs (default: 50)") + est.add_argument("--samples", type=int, default=1000, help="Number of training samples (default: 1000)") + est.add_argument("--sample-size", type=int, default=10, help="Sample size / features (default: 10)") + est.add_argument("--inferences", type=int, default=10000, help="Number of inferences (default: 10000)") + est.add_argument("--hardware", type=str, default=None, help="Hardware profile name") + est.add_argument("--json", action="store_true", dest="output_json", help="Output as JSON") + + # Transformer-specific + est.add_argument("--context-length", type=int, default=10) + est.add_argument("--embedding-size", type=int, default=16) + est.add_argument("--num-heads", type=int, default=2) + est.add_argument("--decoder-blocks", type=int, default=3) + est.add_argument("--feed-forward-size", type=int, default=32) + est.add_argument("--vocab-size", type=int, default=2) + + # CNN-specific + est.add_argument("--conv-layers", type=int, default=3) + est.add_argument("--pool-layers", type=int, default=3) + + # KAN-specific + est.add_argument("--grid-size", type=int, default=10) + + # --- profiles subcommand --- + subparsers.add_parser("profiles", help="List available hardware profiles") + + args = parser.parse_args(argv) + + if args.verbose: + import logging + logging.basicConfig(level=logging.DEBUG) + + if args.command == "estimate": + _run_estimate(args) + elif args.command == "profiles": + _run_profiles() + else: + parser.print_help() + sys.exit(1) + + +def _run_estimate(args): + """Build model parameters from parsed CLI args and print an energy estimate. + + Translates the flat argparse namespace into the ``model_params`` dict + expected by :func:`ecal.api.estimate`, calls it, and prints the result + either as human-readable text or JSON (if ``--json`` was passed). + + Args: + args: Parsed arguments from the ``estimate`` subparser. + """ + from ecal.api import estimate + + model_params = {} + model = args.model.upper() + + if model == "MLP": + model_params = {"num_layers": args.layers, "din": args.din, "dout": args.dout} + elif model == "CNN": + model_params = { + "num_cnv_layers": args.conv_layers, + "num_pool_layers": args.pool_layers, + "i_r": args.sample_size, "i_c": 1, + "k_r": 3, "k_c": 1, "c_in": 1, + } + elif model == "KAN": + model_params = { + "num_layers": args.layers, "grid_size": args.grid_size, + "din": args.din, "dout": args.dout, + } + elif model == "TRANSFORMER": + model_params = { + "context_length": args.context_length, + "embedding_size": args.embedding_size, + "num_heads": args.num_heads, + "num_decoder_blocks": args.decoder_blocks, + "feed_forward_size": args.feed_forward_size, + "vocab_size": args.vocab_size, + } + + result = estimate( + model_type=args.model, + model_params=model_params, + num_samples=args.samples, + sample_size=args.sample_size, + num_epochs=args.epochs, + num_inferences=args.inferences, + hardware=args.hardware, + ) + + if args.output_json: + print(json.dumps(result, indent=2)) + else: + print("\nEnergy Consumption Results (in Joules):") + print("-" * 45) + for key, value in result.items(): + if key == "total_bits": + continue + if isinstance(value, float): + pct = value / result["total"] * 100 if result["total"] > 0 else 0 + print(f" {key:20s}: {value:12.6f} J ({pct:6.2f}%)") + else: + print(f" {key:20s}: {value}") + print(f"\n eCAL: {result['ecal_j_per_bit']:.10e} J/bit") + + +def _run_profiles(): + """Print all available hardware profiles as a formatted table.""" + from ecal.hardware.profiles import list_profiles + + profiles = list_profiles() + print(f"\nAvailable hardware profiles ({len(profiles)}):") + print("-" * 70) + print(f" {'Name':<22s} {'FP32 FLOPS':>14s} {'TDP (W)':>10s} {'Device':<8s}") + print("-" * 70) + for key, p in sorted(profiles.items()): + print(f" {key:<22s} {p.flops_per_second_fp32:>14.2e} {p.tdp_watts:>10.0f} {p.device:<8s}") + + +if __name__ == "__main__": + main() diff --git a/src/ecal/configs/__init__.py b/src/ecal/configs/__init__.py new file mode 100644 index 0000000..7b22a63 --- /dev/null +++ b/src/ecal/configs/__init__.py @@ -0,0 +1,23 @@ +"""eCAL configuration modules.""" + +from ecal.configs.protocol_configs import ( + LayerProtocol, + APPLICATION_PROTOCOLS, + PRESENTATION_PROTOCOLS, + SESSION_PROTOCOLS, + TRANSPORT_PROTOCOLS, + NETWORK_PROTOCOLS, + DATALINK_PROTOCOLS, + PHYSICAL_PROTOCOLS, +) + +__all__ = [ + "LayerProtocol", + "APPLICATION_PROTOCOLS", + "PRESENTATION_PROTOCOLS", + "SESSION_PROTOCOLS", + "TRANSPORT_PROTOCOLS", + "NETWORK_PROTOCOLS", + "DATALINK_PROTOCOLS", + "PHYSICAL_PROTOCOLS", +] diff --git a/src/ecal/configs/protocol_configs.py b/src/ecal/configs/protocol_configs.py new file mode 100644 index 0000000..7479417 --- /dev/null +++ b/src/ecal/configs/protocol_configs.py @@ -0,0 +1,288 @@ +"""OSI-layer protocol energy parameters used by +:class:`ecal.calculators.transmission.Transmission` to estimate the energy +cost of sending data across a network stack. + +Each OSI layer (application, presentation, session, transport, network, +data link, physical) has a dictionary mapping protocol name to a +:class:`LayerProtocol` describing that protocol's overhead and per-bit +energy cost. +""" + +from dataclasses import dataclass + + +@dataclass +class LayerProtocol: + """Protocol metrics for a single layer. + + Args: + name: Protocol name (e.g. "HTTP", "TCP", "IPv4"). + data_plane_overhead: Data-plane overhead ratio — fraction of extra + bits added on top of the payload for framing/formatting at this + layer. + control_plane_overhead: Control-plane overhead ratio — fraction of + extra bits added for control messages/handshakes at this layer. + base_energy_per_bit_sender: Energy consumed per bit by the sender + (Joules/bit). + base_energy_per_bit_receiver: Energy consumed per bit by the + receiver (Joules/bit). + Niot: Number of IoT nodes contributing to this layer's energy draw. + Piot: Power draw per IoT node (watts). + Ngateway: Number of gateway nodes contributing to this layer's + energy draw. + Pgateway: Power draw per gateway node (watts). + """ + name: str + data_plane_overhead: float + control_plane_overhead: float + base_energy_per_bit_sender: float + base_energy_per_bit_receiver: float + Niot: int + Piot: float + Ngateway: int + Pgateway: float + + +# Protocol configurations for each layer +APPLICATION_PROTOCOLS = { + 'HTTP': LayerProtocol( + name='HTTP', + data_plane_overhead=0.1, # 5% headers and data formatting + control_plane_overhead=0.05, # 2% control messages + base_energy_per_bit_sender=0.00000001, # 10 nJ/bit + base_energy_per_bit_receiver=0.00000001, # 10 nJ/bit + Niot=100, + Piot=2 * 1e-10, + Ngateway=100, + Pgateway=1e-10 + ), + 'FTP': LayerProtocol( + name='FTP', + data_plane_overhead=0.03, + control_plane_overhead=0.04, + base_energy_per_bit_sender=0.00000001, + base_energy_per_bit_receiver=0.00000001, + Niot=100, + Piot=2 * 1e-10, + Ngateway=100, + Pgateway=1e-10 + ), + 'Generic_application': LayerProtocol( + name='Generic_application', + data_plane_overhead=0.1, + control_plane_overhead=0.05, + base_energy_per_bit_sender=2e-08, + base_energy_per_bit_receiver=5e-10, + Niot=100, + Piot=2 * 1e-10, + Ngateway=100, + Pgateway=1e-10 + ) +} + +PRESENTATION_PROTOCOLS = { + 'TLS': LayerProtocol( + name='TLS', + data_plane_overhead=0.08, # 8% encryption overhead + control_plane_overhead=0.03, # 3% handshake + base_energy_per_bit_sender=0.00000002, # 20 nJ/bit + base_energy_per_bit_receiver=0.00000002, # 20 nJ/bit + Niot=100, + Piot=2 * 1e-10, + Ngateway=100, + Pgateway=1e-10 + ), + 'SSL': LayerProtocol( + name='SSL', + data_plane_overhead=0.07, + control_plane_overhead=0.04, + base_energy_per_bit_sender=0.00000002, + base_energy_per_bit_receiver=0.00000002, + Niot=100, + Piot=2 * 1e-10, + Ngateway=100, + Pgateway=1e-10 + ), + 'Generic_presentation': LayerProtocol( + name='Generic_presentation', + data_plane_overhead=0.1, + control_plane_overhead=0.05, + base_energy_per_bit_sender=2e-08, + base_energy_per_bit_receiver=5e-10, + Niot=100, + Piot=2 * 1e-10, + Ngateway=100, + Pgateway=1e-10 + ) +} + +SESSION_PROTOCOLS = { + 'RPC': LayerProtocol( + name='RPC', + data_plane_overhead=0.02, + control_plane_overhead=0.02, + base_energy_per_bit_sender=0.00000001, + base_energy_per_bit_receiver=0.00000001, + Niot=100, + Piot=2 * 1e-10, + Ngateway=100, + Pgateway=1e-10 + ), + 'Generic_session': LayerProtocol( + name='Generic_session', + data_plane_overhead=0.1, + control_plane_overhead=0.05, + base_energy_per_bit_sender=2e-08, + base_energy_per_bit_receiver=5e-10, + Niot=100, + Piot=2 * 1e-10, + Ngateway=100, + Pgateway=1e-10 + ) +} + +TRANSPORT_PROTOCOLS = { + 'TCP': LayerProtocol( + name='TCP', + data_plane_overhead=0.05, # 5% segmentation + control_plane_overhead=0.10, # 10% ACKs and control + base_energy_per_bit_sender=0.00000002, # 20 nJ/bit + base_energy_per_bit_receiver=0.00000002, # 20 nJ/bit + Niot=100, + Piot=2 * 1e-10, + Ngateway=100, + Pgateway=1e-10 + ), + 'UDP': LayerProtocol( + name='UDP', + data_plane_overhead=0.02, + control_plane_overhead=0.01, + base_energy_per_bit_sender=0.00000001, + base_energy_per_bit_receiver=0.00000001, + Niot=100, + Piot=2 * 1e-10, + Ngateway=100, + Pgateway=1e-10 + ), + 'Generic_transport': LayerProtocol( + name='Generic_transport', + data_plane_overhead=0.1, + control_plane_overhead=0.05, + base_energy_per_bit_sender=2e-08, + base_energy_per_bit_receiver=5e-10, + Niot=100, + Piot=2 * 1e-10, + Ngateway=100, + Pgateway=1e-10 + + ) + +} + +NETWORK_PROTOCOLS = { + 'IPv4': LayerProtocol( + name='IPv4', + data_plane_overhead=0.03, + control_plane_overhead=0.05, + base_energy_per_bit_sender=0.00000002, + base_energy_per_bit_receiver=0.00000002, + Niot=100, + Piot=2 * 1e-10, + Ngateway=100, + Pgateway=1e-10 + ), + 'IPv6': LayerProtocol( + name='IPv6', + data_plane_overhead=0.04, + control_plane_overhead=0.05, + base_energy_per_bit_sender=0.00000002, + base_energy_per_bit_receiver=0.00000002, + Niot=100, + Piot=2 * 1e-10, + Ngateway=100, + Pgateway=1e-10 + ), + 'Generic_network': LayerProtocol( + name='Generic_network', + data_plane_overhead=0.1, + control_plane_overhead=0.05, + base_energy_per_bit_sender=2e-08, + base_energy_per_bit_receiver=5e-10, + Niot=100, + Piot=2 * 1e-10, + Ngateway=100, + Pgateway=1e-10 + ) +} + +DATALINK_PROTOCOLS = { + 'ETHERNET': LayerProtocol( + name='ETHERNET', + data_plane_overhead=0.05, + control_plane_overhead=0.05, + base_energy_per_bit_sender=0.00000003, + base_energy_per_bit_receiver=0.00000003, + Niot=100, + Piot=2 * 1e-10, + Ngateway=100, + Pgateway=1e-10 + ), + 'WIFI_MAC': LayerProtocol( + name='WIFI_MAC', + data_plane_overhead=0.06, + control_plane_overhead=0.08, + base_energy_per_bit_sender=0.00000004, + base_energy_per_bit_receiver=0.00000004, + Niot=100, + Piot=2 * 1e-10, + Ngateway=100, + Pgateway=1e-10 + ), + 'Generic_datalink': LayerProtocol( + name='Generic_datalink', + data_plane_overhead=0.1, + control_plane_overhead=0.05, + base_energy_per_bit_sender=2e-08, + base_energy_per_bit_receiver=5e-10, + Niot=100, + Piot=2 * 1e-10, + Ngateway=100, + Pgateway=1e-10 + ) +} + +PHYSICAL_PROTOCOLS = { + 'WIFI_PHY': LayerProtocol( + name='WIFI_PHY', + data_plane_overhead=0.10, + control_plane_overhead=0.15, + base_energy_per_bit_sender=0.0000001, + base_energy_per_bit_receiver=0.0000001, + Niot=100, + Piot=2 * 1e-10, + Ngateway=100, + Pgateway=1e-10 + ), + 'BLUETOOTH': LayerProtocol( + name='BLUETOOTH', + data_plane_overhead=0.08, + control_plane_overhead=0.12, + base_energy_per_bit_sender=0.00000005, + base_energy_per_bit_receiver=0.00000005, + Niot=100, + Piot=2 * 1e-10, + Ngateway=100, + Pgateway=1e-10 + ), + 'Generic_physical': LayerProtocol( + name='Generic_physical', + data_plane_overhead=0.1, + control_plane_overhead=0.05, + base_energy_per_bit_sender=2e-08, + base_energy_per_bit_receiver=5e-10, + Niot=100, + Piot=2 * 1e-10, + Ngateway=100, + Pgateway=1e-10 + ) +} diff --git a/src/ecal/hardware/__init__.py b/src/ecal/hardware/__init__.py new file mode 100644 index 0000000..5823415 --- /dev/null +++ b/src/ecal/hardware/__init__.py @@ -0,0 +1,5 @@ +"""eCAL hardware profile management.""" + +from ecal.hardware.profiles import HardwareProfile, get_profile, list_profiles + +__all__ = ["HardwareProfile", "get_profile", "list_profiles"] diff --git a/src/ecal/hardware/data/profiles.yaml b/src/ecal/hardware/data/profiles.yaml new file mode 100644 index 0000000..b9dae08 --- /dev/null +++ b/src/ecal/hardware/data/profiles.yaml @@ -0,0 +1,44 @@ +apple_m2: + name: "Apple M2" + flops_per_second_fp32: 3.6e12 + flops_per_second_fp16: 7.2e12 + tdp_watts: 22 + gpu_power_watts: 15 + idle_power_watts: 3 + device: "mps" + +nvidia_a100_80gb: + name: "NVIDIA A100 80GB" + flops_per_second_fp32: 19.5e12 + flops_per_second_fp16: 77.97e12 + tdp_watts: 400 + gpu_power_watts: 250 + idle_power_watts: 50 + device: "cuda" + +nvidia_h100_sxm: + name: "NVIDIA H100 SXM" + flops_per_second_fp32: 67e12 + flops_per_second_fp16: 267.7e12 + tdp_watts: 700 + gpu_power_watts: 600 + idle_power_watts: 100 + device: "cuda" + +generic_cpu: + name: "Generic CPU" + flops_per_second_fp32: 1e12 + flops_per_second_fp16: 1e12 + tdp_watts: 100 + gpu_power_watts: 100 + idle_power_watts: 20 + device: "cpu" + +generic_edge: + name: "Generic Edge Device" + flops_per_second_fp32: 1e10 + flops_per_second_fp16: 2e10 + tdp_watts: 15 + gpu_power_watts: 10 + idle_power_watts: 2 + device: "cpu" diff --git a/src/ecal/hardware/profiles.py b/src/ecal/hardware/profiles.py new file mode 100644 index 0000000..eed5f6d --- /dev/null +++ b/src/ecal/hardware/profiles.py @@ -0,0 +1,84 @@ +import os +from dataclasses import dataclass +from typing import Dict + +import yaml + + +@dataclass +class HardwareProfile: + """Hardware profile for energy estimation. + + Args: + name: Human-readable device name (e.g. "NVIDIA H100 SXM"). + flops_per_second_fp32: Peak throughput in FLOPS at FP32 precision. + flops_per_second_fp16: Peak throughput in FLOPS at FP16 precision. + tdp_watts: Thermal design power in watts; used as the processor's + power draw when converting FLOP-derived time into energy. + gpu_power_watts: GPU power draw in watts under load (0 for CPU-only + profiles). + idle_power_watts: Idle power draw in watts. + device: Backend device identifier (e.g. "cpu", "cuda", "mps"). + """ + name: str + flops_per_second_fp32: float + flops_per_second_fp16: float + tdp_watts: float + gpu_power_watts: float + idle_power_watts: float + device: str + + +_DATA_DIR = os.path.join(os.path.dirname(__file__), "data") +_PROFILES_CACHE: Dict[str, HardwareProfile] = {} + + +def _load_profiles() -> Dict[str, HardwareProfile]: + """Load hardware profiles from YAML file.""" + if _PROFILES_CACHE: + return _PROFILES_CACHE + + profiles_path = os.path.join(_DATA_DIR, "profiles.yaml") + with open(profiles_path, "r") as f: + data = yaml.safe_load(f) + + for key, vals in data.items(): + _PROFILES_CACHE[key] = HardwareProfile( + name=vals["name"], + flops_per_second_fp32=float(vals["flops_per_second_fp32"]), + flops_per_second_fp16=float(vals["flops_per_second_fp16"]), + tdp_watts=float(vals["tdp_watts"]), + gpu_power_watts=float(vals["gpu_power_watts"]), + idle_power_watts=float(vals["idle_power_watts"]), + device=vals["device"], + ) + + return _PROFILES_CACHE + + +def get_profile(name: str) -> HardwareProfile: + """Get a hardware profile by name. + + Args: + name: Profile identifier (e.g., 'apple_m2', 'nvidia_h100_sxm', 'generic_cpu') + + Returns: + HardwareProfile dataclass + + Raises: + KeyError: If profile name is not found + """ + profiles = _load_profiles() + if name not in profiles: + available = ", ".join(sorted(profiles.keys())) + raise KeyError(f"Unknown hardware profile '{name}'. Available: {available}") + return profiles[name] + + +def list_profiles() -> Dict[str, HardwareProfile]: + """List all available hardware profiles. + + Returns: + Dictionary mapping profile names to HardwareProfile objects + """ + return dict(_load_profiles()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..80c9eed --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,58 @@ +"""Shared fixtures for eCAL tests.""" + +import pytest + +from ecal.calculators.transmission import Transmission +from ecal.calculators.preprocessing import DataPreprocessing +from ecal.calculators.model_flops import ( + MLPCalculator, + CNNCalculator, + KANCalculator, + TransformerCalculator, +) + + +@pytest.fixture +def mlp_calculator(): + return MLPCalculator(num_layers=3, din=10, dout=2) + + +@pytest.fixture +def cnn_calculator(): + return CNNCalculator(num_cnv_layers=3, num_pool_layers=3, i_r=10, i_c=1, k_r=3, k_c=1, c_in=1) + + +@pytest.fixture +def kan_calculator(): + return KANCalculator(num_layers=3, grid_size=10, din=10, dout=2) + + +@pytest.fixture +def transformer_calculator(): + return TransformerCalculator( + context_length=10, embedding_size=16, num_heads=2, + num_decoder_blocks=3, feed_forward_size=32, vocab_size=2, + ) + + +@pytest.fixture +def generic_transmission(): + return Transmission( + application="Generic_application", + presentation="Generic_presentation", + session="Generic_session", + transport="Generic_transport", + network="Generic_network", + datalink="Generic_datalink", + physical="Generic_physical", + failure_rate=0.0, + ) + + +@pytest.fixture +def normalization_preprocessing(): + return DataPreprocessing( + preprocessing_type="normalization", + processor_flops_per_second=1e10, + processor_max_power=100, + ) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..bfdb476 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,105 @@ +"""Integration tests for ecal.estimate().""" + +import pytest +from ecal.api import estimate + + +class TestEstimate: + def test_mlp_estimate(self): + result = estimate( + model_type="MLP", + model_params={"num_layers": 3, "din": 10, "dout": 2}, + num_samples=100, + num_epochs=5, + num_inferences=100, + ) + assert result["total"] > 0 + assert result["ecal_j_per_bit"] > 0 + assert result["training"] > 0 + assert result["inference"] > 0 + + def test_cnn_estimate(self): + result = estimate( + model_type="CNN", + model_params={"num_cnv_layers": 3, "num_pool_layers": 3}, + num_samples=100, + num_epochs=5, + num_inferences=100, + ) + assert result["total"] > 0 + + def test_kan_estimate(self): + result = estimate( + model_type="KAN", + model_params={"num_layers": 3, "grid_size": 10, "din": 10, "dout": 2}, + num_samples=100, + num_epochs=5, + num_inferences=100, + ) + assert result["total"] > 0 + + def test_transformer_estimate(self): + result = estimate( + model_type="Transformer", + model_params={ + "context_length": 10, + "embedding_size": 16, + "num_heads": 2, + "num_decoder_blocks": 3, + "feed_forward_size": 32, + "vocab_size": 2, + }, + num_samples=100, + num_epochs=5, + num_inferences=100, + ) + assert result["total"] > 0 + + def test_hardware_profile(self): + result = estimate( + model_type="MLP", + model_params={"num_layers": 3, "din": 10, "dout": 2}, + hardware="generic_cpu", + num_samples=100, + num_epochs=5, + num_inferences=100, + ) + assert result["total"] > 0 + + def test_unsupported_model_raises(self): + with pytest.raises(ValueError, match="Unsupported model type"): + estimate(model_type="LSTM") + + def test_result_keys(self): + result = estimate( + model_type="MLP", + model_params={"num_layers": 3, "din": 10, "dout": 2}, + num_samples=100, + num_epochs=5, + ) + expected_keys = { + "transmission", "preprocessing", "training", "evaluation", + "inference", "inference_process", "total", "ecal_j_per_bit", + "Ed bits", "inf_proc_bits", "total_bits", + } + assert expected_keys == set(result.keys()) + + def test_virtualization_overhead(self): + r1 = estimate( + model_type="MLP", + model_params={"num_layers": 3, "din": 10, "dout": 2}, + num_samples=100, num_epochs=5, + virtualization_overhead=0.0, + ) + r2 = estimate( + model_type="MLP", + model_params={"num_layers": 3, "din": 10, "dout": 2}, + num_samples=100, num_epochs=5, + virtualization_overhead=0.5, + ) + assert r2["total"] > r1["total"] + + def test_default_params(self): + """Test that estimate works with minimal parameters.""" + result = estimate(model_type="MLP") + assert result["total"] > 0 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..27902fd --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,42 @@ +"""Smoke tests for the CLI.""" + +import pytest +from ecal.cli import main + + +class TestCLI: + def test_version(self, capsys): + with pytest.raises(SystemExit, match="0"): + main(["--version"]) + captured = capsys.readouterr() + assert "ecal 0.1.0" in captured.out + + def test_estimate_mlp(self, capsys): + main(["estimate", "--model", "MLP", "--layers", "3", "--epochs", "5", + "--samples", "100", "--inferences", "100"]) + captured = capsys.readouterr() + assert "eCAL:" in captured.out + + def test_estimate_json(self, capsys): + main(["estimate", "--model", "MLP", "--layers", "3", "--epochs", "5", + "--samples", "100", "--json"]) + import json + captured = capsys.readouterr() + result = json.loads(captured.out) + assert "total" in result + + def test_estimate_with_hardware(self, capsys): + main(["estimate", "--model", "MLP", "--layers", "3", "--epochs", "5", + "--samples", "100", "--hardware", "generic_cpu"]) + captured = capsys.readouterr() + assert "eCAL:" in captured.out + + def test_profiles(self, capsys): + main(["profiles"]) + captured = capsys.readouterr() + assert "generic_cpu" in captured.out + assert "apple_m2" in captured.out + + def test_no_command_exits(self): + with pytest.raises(SystemExit, match="1"): + main([]) diff --git a/tests/test_hardware_profiles.py b/tests/test_hardware_profiles.py new file mode 100644 index 0000000..a9f451e --- /dev/null +++ b/tests/test_hardware_profiles.py @@ -0,0 +1,38 @@ +"""Tests for hardware profiles.""" + +import pytest +from ecal.hardware.profiles import get_profile, list_profiles, HardwareProfile + + +class TestHardwareProfiles: + def test_list_profiles(self): + profiles = list_profiles() + assert len(profiles) >= 5 + assert "generic_cpu" in profiles + assert "apple_m2" in profiles + assert "nvidia_h100_sxm" in profiles + + def test_get_profile(self): + profile = get_profile("generic_cpu") + assert isinstance(profile, HardwareProfile) + assert profile.name == "Generic CPU" + assert profile.flops_per_second_fp32 > 0 + assert profile.tdp_watts > 0 + assert profile.device == "cpu" + + def test_get_apple_m2(self): + profile = get_profile("apple_m2") + assert profile.device == "mps" + assert profile.flops_per_second_fp32 > 1e12 + + def test_unknown_profile_raises(self): + with pytest.raises(KeyError, match="Unknown hardware profile"): + get_profile("nonexistent_gpu_9000") + + def test_all_profiles_have_required_fields(self): + for name, profile in list_profiles().items(): + assert profile.name + assert profile.flops_per_second_fp32 > 0 + assert profile.flops_per_second_fp16 > 0 + assert profile.tdp_watts > 0 + assert profile.device in ("cpu", "cuda", "mps") diff --git a/tests/test_inference.py b/tests/test_inference.py new file mode 100644 index 0000000..7d00d88 --- /dev/null +++ b/tests/test_inference.py @@ -0,0 +1,45 @@ +"""Tests for the Inference calculator.""" + +import pytest +from ecal.calculators.inference import Inference + + +class TestInference: + def test_inference_energy_positive(self, mlp_calculator): + inf = Inference( + model_name="MLP", + input_size=(1, 10), + num_samples=1000, + processor_flops_per_second=1e13, + processor_max_power=100, + calculator=mlp_calculator, + ) + energy = inf.calculate_energy() + assert energy > 0 + + def test_inference_flops_positive(self, mlp_calculator): + inf = Inference( + model_name="MLP", + input_size=(1, 10), + num_samples=1000, + processor_flops_per_second=1e13, + processor_max_power=100, + calculator=mlp_calculator, + ) + flops = inf.calculate_flops() + assert flops > 0 + + def test_more_inferences_more_energy(self, mlp_calculator): + def make_inf(n): + return Inference( + model_name="MLP", + input_size=(1, 10), + num_samples=n, + processor_flops_per_second=1e13, + processor_max_power=100, + calculator=mlp_calculator, + ) + + e1 = make_inf(100).calculate_energy() + e2 = make_inf(10000).calculate_energy() + assert e2 > e1 diff --git a/tests/test_model_flops.py b/tests/test_model_flops.py new file mode 100644 index 0000000..be0166e --- /dev/null +++ b/tests/test_model_flops.py @@ -0,0 +1,51 @@ +"""Tests for FLOP calculators.""" + +import pytest + + +class TestMLPCalculator: + def test_positive_flops(self, mlp_calculator): + result = mlp_calculator.calculate(None, (1, 10)) + assert result["total_flops"] > 0 + + def test_more_layers_more_flops(self): + from ecal.calculators.model_flops import MLPCalculator + c3 = MLPCalculator(num_layers=3, din=10, dout=2) + c6 = MLPCalculator(num_layers=6, din=10, dout=2) + f3 = c3.calculate(None, (1, 10))["total_flops"] + f6 = c6.calculate(None, (1, 10))["total_flops"] + assert f6 > f3 + + +class TestCNNCalculator: + def test_positive_flops(self, cnn_calculator): + result = cnn_calculator.calculate(None, (1, 1, 10)) + assert result["total_flops"] > 0 + + +class TestKANCalculator: + def test_positive_flops(self, kan_calculator): + result = kan_calculator.calculate(None, (1, 10)) + assert result["total_flops"] > 0 + + def test_more_layers_more_flops(self): + from ecal.calculators.model_flops import KANCalculator + c2 = KANCalculator(num_layers=2, grid_size=10, din=10, dout=2) + c5 = KANCalculator(num_layers=5, grid_size=10, din=10, dout=2) + f2 = c2.calculate(None, (1, 10))["total_flops"] + f5 = c5.calculate(None, (1, 10))["total_flops"] + assert f5 > f2 + + +class TestTransformerCalculator: + def test_positive_flops(self, transformer_calculator): + result = transformer_calculator.calculate(None, (1, 10)) + assert result["total_flops"] > 0 + assert "breakdown" in result + + def test_breakdown_structure(self, transformer_calculator): + result = transformer_calculator.calculate(None, (1, 10)) + breakdown = result["breakdown"] + assert "attention" in breakdown + assert "mlp_blocks_flops" in breakdown + assert "per_block_flops" in breakdown diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py new file mode 100644 index 0000000..8f9c8d0 --- /dev/null +++ b/tests/test_preprocessing.py @@ -0,0 +1,61 @@ +"""Tests for the DataPreprocessing calculator.""" + +import pytest +from ecal.calculators.preprocessing import DataPreprocessing +from ecal.calculators.preprocessing_flops import ( + NormalizationCalculator, + MinMaxScalingCalculator, + GramianDifferenceFieldCalculator, +) + + +class TestPreprocessingFlops: + def test_normalization_flops(self): + calc = NormalizationCalculator() + result = calc.calculate_flops(100) + assert result["total_flops"] == 6 * 100 + 1 + + def test_min_max_scaling_flops(self): + calc = MinMaxScalingCalculator() + result = calc.calculate_flops(100) + assert result["total_flops"] == 2 * 100 + 1 + + def test_gadf_flops(self): + calc = GramianDifferenceFieldCalculator() + result = calc.calculate_flops(data_size=10, time_steps=5) + assert result["total_flops"] > 0 + assert result["data_shape"] == (10, 5, 5) + + def test_normalization_more_data_more_flops(self): + calc = NormalizationCalculator() + f1 = calc.calculate_flops(100)["total_flops"] + f2 = calc.calculate_flops(1000)["total_flops"] + assert f2 > f1 + + +class TestDataPreprocessing: + def test_normalization_energy(self, normalization_preprocessing): + result = normalization_preprocessing.calculate_energy(1000, 10) + assert result["total_energy"] > 0 + + def test_min_max_energy(self): + pp = DataPreprocessing( + preprocessing_type="min_max_scaling", + processor_flops_per_second=1e10, + processor_max_power=100, + ) + result = pp.calculate_energy(1000, 10) + assert result["total_energy"] > 0 + + def test_gadf_energy(self): + pp = DataPreprocessing( + preprocessing_type="GADF", + processor_flops_per_second=1e10, + processor_max_power=100, + ) + result = pp.calculate_energy(100, 10) + assert result["total_energy"] > 0 + + def test_invalid_preprocessing_type(self): + with pytest.raises(ValueError, match="Unsupported preprocessing type"): + DataPreprocessing(preprocessing_type="nonexistent") diff --git a/tests/test_training.py b/tests/test_training.py new file mode 100644 index 0000000..1ce8427 --- /dev/null +++ b/tests/test_training.py @@ -0,0 +1,111 @@ +"""Tests for the Training calculator.""" + +import pytest +from ecal.calculators.training import Training +from ecal.calculators.model_flops import MLPCalculator + + +class TestTraining: + @pytest.fixture + def mlp_training(self, mlp_calculator): + return Training( + model_name="MLP", + num_epochs=10, + batch_size=32, + processor_flops_per_second=1e13, + processor_max_power=100, + num_samples=1000, + input_size=(1, 10), + evaluation_strategy="cross_validation", + k_folds=5, + split_ratio=0.8, + calculator=mlp_calculator, + ) + + def test_training_energy_positive(self, mlp_training): + result = mlp_training.calculate_energy() + assert result["training_energy"] > 0 + assert result["evaluation_energy"] > 0 + assert result["total_energy"] > 0 + + def test_training_flops_positive(self, mlp_training): + flops = mlp_training.calculate_flops_training() + assert flops > 0 + + def test_evaluation_flops_positive(self, mlp_training): + flops = mlp_training.calculate_flops_evaluation() + assert flops > 0 + + def test_time_inversion_regression(self, mlp_calculator): + """Regression test: train_time should be flops/flops_per_sec, not inverted.""" + training = Training( + model_name="MLP", + num_epochs=10, + batch_size=32, + processor_flops_per_second=1e13, + processor_max_power=100, + num_samples=1000, + input_size=(1, 10), + evaluation_strategy="cross_validation", + k_folds=5, + split_ratio=0.8, + calculator=mlp_calculator, + ) + result = training.calculate_energy() + # train_time = training_flops / processor_flops_per_second + # For small models, train_time should be small (< 1 sec), not huge + assert result["train_time"] < 100 # Should be tiny, not 1e13/flops + assert result["train_time"] == result["training_flops"] / 1e13 + + def test_train_test_split_strategy(self, mlp_calculator): + training = Training( + model_name="MLP", + num_epochs=10, + batch_size=32, + processor_flops_per_second=1e13, + processor_max_power=100, + num_samples=1000, + input_size=(1, 10), + evaluation_strategy="train_test_split", + k_folds=5, + split_ratio=0.8, + calculator=mlp_calculator, + ) + result = training.calculate_energy() + assert result["total_energy"] > 0 + + def test_invalid_evaluation_strategy(self, mlp_calculator): + with pytest.raises(ValueError, match="Unsupported evaluation strategy"): + Training( + model_name="MLP", + num_epochs=10, + batch_size=32, + processor_flops_per_second=1e13, + processor_max_power=100, + num_samples=1000, + input_size=(1, 10), + evaluation_strategy="bogus", + k_folds=5, + split_ratio=0.8, + calculator=mlp_calculator, + ) + + def test_more_epochs_more_energy(self, mlp_calculator): + def make_training(epochs): + return Training( + model_name="MLP", + num_epochs=epochs, + batch_size=32, + processor_flops_per_second=1e13, + processor_max_power=100, + num_samples=1000, + input_size=(1, 10), + evaluation_strategy="cross_validation", + k_folds=5, + split_ratio=0.8, + calculator=mlp_calculator, + ) + + e10 = make_training(10).calculate_energy()["training_energy"] + e100 = make_training(100).calculate_energy()["training_energy"] + assert e100 > e10 diff --git a/tests/test_transmission.py b/tests/test_transmission.py new file mode 100644 index 0000000..f8f2f5f --- /dev/null +++ b/tests/test_transmission.py @@ -0,0 +1,57 @@ +"""Tests for the Transmission calculator.""" + +import pytest +from ecal.calculators.transmission import Transmission + + +class TestTransmission: + def test_zero_failure_rate(self, generic_transmission): + result = generic_transmission.calculate_energy(1000) + assert result["total_energy"] > 0 + assert result["expected_transmissions"] == 1.0 + assert result["failure_rate"] == 0.0 + + def test_increasing_failure_increases_energy(self): + tx_0 = Transmission( + application="Generic_application", presentation="Generic_presentation", + session="Generic_session", transport="Generic_transport", + network="Generic_network", datalink="Generic_datalink", + physical="Generic_physical", failure_rate=0.0, + ) + tx_50 = Transmission( + application="Generic_application", presentation="Generic_presentation", + session="Generic_session", transport="Generic_transport", + network="Generic_network", datalink="Generic_datalink", + physical="Generic_physical", failure_rate=0.5, + ) + e0 = tx_0.calculate_energy(1000)["total_energy"] + e50 = tx_50.calculate_energy(1000)["total_energy"] + assert e50 > e0 + + def test_invalid_failure_rate(self): + with pytest.raises(ValueError, match="Failure rate must be between 0 and 1"): + Transmission(failure_rate=1.5) + + def test_negative_failure_rate(self): + with pytest.raises(ValueError, match="Failure rate must be between 0 and 1"): + Transmission(failure_rate=-0.1) + + def test_layer_breakdown_present(self, generic_transmission): + result = generic_transmission.calculate_energy(640000) + assert "layer_breakdown" in result + assert "application" in result["layer_breakdown"] + assert "physical" in result["layer_breakdown"] + + def test_more_bits_more_energy(self, generic_transmission): + e_small = generic_transmission.calculate_energy(100)["total_energy"] + e_large = generic_transmission.calculate_energy(10000)["total_energy"] + assert e_large > e_small + + def test_specific_protocols(self): + tx = Transmission( + application="HTTP", presentation="TLS", session="RPC", + transport="TCP", network="IPv4", datalink="ETHERNET", + physical="WIFI_PHY", failure_rate=0.0, + ) + result = tx.calculate_energy(1000) + assert result["total_energy"] > 0 diff --git a/website/index.html b/website/index.html new file mode 100644 index 0000000..4887732 --- /dev/null +++ b/website/index.html @@ -0,0 +1,730 @@ + + +
+ + +Training and inference are only part of the bill. eCAL is the first metric that adds up the + energy of data collection, preprocessing, training, evaluation, and inference — end to end, in Joules per bit — + so you can see the true cost of adding AI to a communication system.
+ + +ecal-energy) — the same case-study configuration and the same sampled inference counts (γ = 10²…10⁸) as the paper's Fig. 13.Telecom metrics stop at the network. Deep-learning metrics stop at the model. No one connects + the two — so nobody can say what it actually costs, in energy, to make a communication system intelligent.
+Energy-per-Bit, PUE, CUE, and WUE measure the network and data-center layer with precision — but they have no notion of what the AI model on top is actually computing.
+APC, APEC, TTCAPC, and TTCAPEC weigh model accuracy against training or inference cost — but most don't even cover inference, and none account for the energy spent collecting the data in the first place.
+Without a metric spanning the full OSI-to-MLOps pipeline, nobody can compare the true energy cost of "adding intelligence" to a network, or predict how that cost falls as a model is used more.
+eCAL formalizes every stage of the AI lifecycle as a standard OSI/MLOps data-manipulation + component, then reduces the whole pipeline to one closed-form number in Joules per bit.
+A formal breakdown of data collection, preprocessing, training, evaluation, and inference as standardized OSI and MLOps "data manipulation components" — the shared vocabulary the rest of eCAL builds on.
Closed-form energy formulas across all 7 OSI layers — retransmission rates, protocol overheads, wired and wireless links — validated against real Wi-Fi 6 and 5G measurement studies.
Exact FLOP formulas for MLP, CNN, KAN, and Transformer models, mapped to real hardware power and FLOPS profiles — from an Apple M2 to an NVIDIA H100 — with pluggable support for ResNet, VGG, and Baichuan-style LLMs.
A single closed-form J/bit expression spanning the whole lifecycle, shipped as a modular, extensible Python package — pip install ecal-energy — with both a CLI and an API.
eCAL reduces data collection, preprocessing, training, evaluation, and inference to a single + Joules-per-bit number — so the cost of developing a model and the cost of running it can be compared on the + same scale.
+ED is the one-time development energy — data collection, preprocessing, training, and + evaluation. Einf,p is the energy of a single inference pass. γ is how many times the deployed + model is actually run; γᵥ captures virtualization overhead. f, IS, NS, and NI,P + normalize everything to Joules per bit of data processed.
+Validated against real Wi-Fi 6 and 5G measurement datasets, where eCAL acts as a tight upper bound on observed energy cost.
+Grounded in a formal lifecycle methodology, validated against real network measurement + studies, and open-sourced as a working tool — not just a paper.
+Introduces eCAL, the first metric to span the full AI lifecycle — data collection, preprocessing, + training, evaluation, and inference — in a single Joules-per-bit number, validated on a real case-study + pipeline and against Wi-Fi 6 / 5G measurement data.
+ecal-energy)Applies the eCAL lifecycle methodology to O-RAN AI/ML workflows, quantifying the energy footprint of + adding machine learning to next-generation radio access networks.
+The earliest work in this research line, arguing for standardized, lifecycle-aware energy-efficiency + metrics as AI becomes native to 6G and future communication networks.
+eCAL ships as an open-source Python package with a CLI and an API — plug in your architecture, + hardware, and dataset size, and get a J/bit estimate in seconds.
+$ pip install ecal-energy +$ ecal estimate --model MLP --layers 3 --epochs 50 --hardware apple_m2+
import ecal + +result = ecal.estimate( + model_type="MLP", + model_params={"num_layers": 3, "din": 10, "dout": 2}, + num_samples=1000, num_epochs=50, + hardware="apple_m2", +) +print(result["ecal_j_per_bit"])+
ecal-energyEstimate the full lifecycle cost of a model — not just training, not just inference — before committing hardware or bandwidth to it.
MLP, CNN, KAN, or Transformer — eCAL puts every architecture on the same J/bit scale, so efficiency claims can be compared directly instead of by proxy.
Open-source and modular: plug in custom protocols, architectures, or hardware profiles instead of being limited to what ships out of the box.
Whether you're building energy-aware AI systems, researching sustainable networking, or want to extend + eCAL to a new architecture or protocol — we'd like to hear from you.
+ +