From 481c91185f3ca67bde5787ffd1ac26380e661741 Mon Sep 17 00:00:00 2001 From: Carolina Date: Tue, 10 Mar 2026 17:10:45 +0100 Subject: [PATCH 1/9] Add installable ecal-energy package with CLI, API, tests, and updated calculators Restructure project as a pip-installable package (src/ecal/) with a public API, CLI entry point, hardware profiles, and full test suite. Update all existing calculators, configs, README, and requirements. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 6 + README.md | 140 ++++++- RunCalculator.py | 50 --- calculators/DataPreprocessing.py | 62 +-- calculators/Inference.py | 63 +--- calculators/ModelFLOPS.py | 285 ++------------ calculators/PreprocessingFLOPS.py | 84 +---- calculators/ToyModels.py | 355 ++---------------- calculators/Training.py | 117 +----- calculators/Transmission.py | 110 +----- calculators/__init__.py | 1 + configs/ProtocolConfigs.py | 271 +------------ configs/SimpleMultiModelConfig.py | 2 +- configs/__init__.py | 1 + notebooks/allTrainingEnergy.py | 20 +- pyproject.toml | 77 ++++ requirements.txt | 14 +- ...l_Theoretical_Empirical_Training_Energy.py | 20 +- src/ecal/__init__.py | 6 + src/ecal/_version.py | 1 + src/ecal/api.py | 246 ++++++++++++ src/ecal/calculators/__init__.py | 41 ++ src/ecal/calculators/inference.py | 61 +++ src/ecal/calculators/model_flops.py | 263 +++++++++++++ src/ecal/calculators/preprocessing.py | 64 ++++ src/ecal/calculators/preprocessing_flops.py | 70 ++++ src/ecal/calculators/toy_models.py | 331 ++++++++++++++++ src/ecal/calculators/training.py | 114 ++++++ src/ecal/calculators/transmission.py | 117 ++++++ src/ecal/cli.py | 136 +++++++ src/ecal/configs/__init__.py | 23 ++ src/ecal/configs/protocol_configs.py | 260 +++++++++++++ src/ecal/hardware/__init__.py | 5 + src/ecal/hardware/data/profiles.yaml | 44 +++ src/ecal/hardware/profiles.py | 72 ++++ tests/__init__.py | 0 tests/conftest.py | 58 +++ tests/test_api.py | 105 ++++++ tests/test_cli.py | 42 +++ tests/test_hardware_profiles.py | 38 ++ tests/test_inference.py | 45 +++ tests/test_model_flops.py | 51 +++ tests/test_preprocessing.py | 61 +++ tests/test_training.py | 111 ++++++ tests/test_transmission.py | 57 +++ 45 files changed, 2728 insertions(+), 1372 deletions(-) create mode 100644 pyproject.toml create mode 100644 src/ecal/__init__.py create mode 100644 src/ecal/_version.py create mode 100644 src/ecal/api.py create mode 100644 src/ecal/calculators/__init__.py create mode 100644 src/ecal/calculators/inference.py create mode 100644 src/ecal/calculators/model_flops.py create mode 100644 src/ecal/calculators/preprocessing.py create mode 100644 src/ecal/calculators/preprocessing_flops.py create mode 100644 src/ecal/calculators/toy_models.py create mode 100644 src/ecal/calculators/training.py create mode 100644 src/ecal/calculators/transmission.py create mode 100644 src/ecal/cli.py create mode 100644 src/ecal/configs/__init__.py create mode 100644 src/ecal/configs/protocol_configs.py create mode 100644 src/ecal/hardware/__init__.py create mode 100644 src/ecal/hardware/data/profiles.yaml create mode 100644 src/ecal/hardware/profiles.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_api.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_hardware_profiles.py create mode 100644 tests/test_inference.py create mode 100644 tests/test_model_flops.py create mode 100644 tests/test_preprocessing.py create mode 100644 tests/test_training.py create mode 100644 tests/test_transmission.py diff --git a/.gitignore b/.gitignore index 5660c2b..805c96b 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] diff --git a/README.md b/README.md index 71469df..5f129a9 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,139 @@ # 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**. ## Installation -To install the required dependencies, run the following command: + +### From source (recommended for development) + +```bash +git clone https://github.com/cfortuna/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 | 300 | 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 +146,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 +159,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 +168,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..ab2f27e 100644 --- a/calculators/ToyModels.py +++ b/calculators/ToyModels.py @@ -1,331 +1,24 @@ -import torch.nn as nn -import torch.nn.functional as F -from efficient_kan import KAN - -import torch -import numpy as np - -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: - x = layer(x) - return self.output(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): - super(SimpleMLP_practical, 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 - - -# --- CNN Model (Modified to vary dense layers) --- -class SimpleCNN_practical(nn.Module): - """ - A simple 1D Convolutional Neural Network (CNN) for sequence or feature vector regression. - """ - def __init__(self, input_size=10, output_size=2, hidden_channels=16, num_layers=3): - super(SimpleCNN_practical, self).__init__() - self.layers = nn.ModuleList() - # Input data is expected to be reshaped to have 1 channel. - current_channels = 1 - - # Create convolutional blocks with increasing channel depth - for i in range(1, num_layers): - out_channels = hidden_channels * (2 ** i) - 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 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: - # x = x.unsqueeze(1) # Add channel dimension if missing - # Expected input x shape: (batch_size, 1, num_features) - for layer in self.layers: - x = layer(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): - def __init__(self, num_layers: int, grid_size: int = 10, din: int = 10, dout: int = 2, k: int = 3): - super(KANLikeRegressor, self).__init__() - self.input_dim = din - - architecture = [din] + [din] * (num_layers) + [dout] - - self.kan = KAN(architecture, grid_size=grid_size, - spline_order=3) - - def forward(self, x): - # Simpler forward pass that processes all layers sequentially - for layer in self.kan.layers: - x = layer(x) - return x - - -#############################TRANSFORMER MODEL FROM SCRATCH############################# - -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 - - # 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 - 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) - 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 - - -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) - # LayerNorm - x = self.layer_norm1(x_attn + x) - # MLP - x_mlp = self.mlp(x) - # LayerNorm - x = self.layer_norm2(x_mlp + 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 - 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': - loss_matrix = loss_matrix.view(B, T) - 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 - self.num_emb = num_emb - self.num_blocks = num_blocks - - # FIX 2: Re-enable the positional embedding layer - self.positional_embedding = nn.Embedding(num_tokens, num_emb) - - # This correctly projects each feature in the sequence to the embedding dimension - self.embedding = nn.Linear(1, num_emb) - - # transformer blocks - self.transformer_blocks = nn.ModuleList() - for _ in range(num_blocks): - self.transformer_blocks.append(TransformerBlock(num_emb=num_emb, num_neurons=num_neurons, num_heads=num_heads)) - - # Output layer for regression (predicting 2 values: target_x, target_y) - regression_output_dim = 1 - self.output_layer = nn.Sequential(nn.Linear(num_emb * num_tokens, regression_output_dim)) - - # dropout layer - self.dropout = nn.Dropout(dropout_prob) - - - def transformer_forward(self, x, causal=True): - # x starts as: B(atch) x T(okens) -> (32, 25) - - # FIX 1: Add a dimension to the input for the linear embedding layer - # x becomes: B x T x 1 -> (32, 25, 1) - x = x.unsqueeze(-1) - - # embedding of tokens - # x becomes B x T x D(im) -> (32, 25, 20) - x = self.embedding(x) - - # embedding of positions - # Create positions tensor: (1, 25) - pos = torch.arange(0, x.shape[1], dtype=torch.long).unsqueeze(0).to(self.device) - # Get positional embeddings: (1, 25, 20) - pos_emb = self.positional_embedding(pos) - - # Add positional embeddings to input embeddings - x = self.dropout(x + pos_emb) - - # transformer blocks - for i in range(self.num_blocks): - x = self.transformer_blocks[i](x, causal=False) # Causal might not be needed for this task - - # Flatten the output of the transformer blocks - # x shape from (B, T, D) to (B, T * D) -> (32, 25 * 20) - x = x.view(x.size(0), -1) - - # output layer - out = self.output_layer(x) - - return out - - 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 +"""Backward-compatibility shim — use ecal.calculators.toy_models instead.""" +from ecal.calculators.toy_models import ( # noqa: F401 + SimpleMLP, + SimpleCNN, + SimpleMLP_practical, + SimpleCNN_practical, + KANLikeRegressor, + MultiHeadSelfAttention, + TransformerBlock, + LossFun, + Transformer, +) + +__all__ = [ + "SimpleMLP", + "SimpleCNN", + "SimpleMLP_practical", + "SimpleCNN_practical", + "KANLikeRegressor", + "MultiHeadSelfAttention", + "TransformerBlock", + "LossFun", + "Transformer", +] 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/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..33228aa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,77 @@ +[build-system] +requires = ["setuptools>=68.0", "setuptools-scm>=8.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"}, +] +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", +] + +[project.scripts] +ecal = "ecal.cli:main" + +[project.urls] +Homepage = "https://github.com/cfortuna/eCAL" +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..e281ecb --- /dev/null +++ b/src/ecal/api.py @@ -0,0 +1,246 @@ +"""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: num_layers, din, dout + - CNN: num_cnv_layers, num_pool_layers, i_r, i_c, k_r, k_c, c_in + - KAN: num_layers, grid_size, din, dout + - Transformer: 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) + """ + 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..2e96a1e --- /dev/null +++ b/src/ecal/calculators/inference.py @@ -0,0 +1,61 @@ +from typing import Dict, Union, 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) -> 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 diff --git a/src/ecal/calculators/model_flops.py b/src/ecal/calculators/model_flops.py new file mode 100644 index 0000000..e566bb8 --- /dev/null +++ b/src/ecal/calculators/model_flops.py @@ -0,0 +1,263 @@ +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), + }} diff --git a/src/ecal/calculators/preprocessing.py b/src/ecal/calculators/preprocessing.py new file mode 100644 index 0000000..6ba15cf --- /dev/null +++ b/src/ecal/calculators/preprocessing.py @@ -0,0 +1,64 @@ +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 + """ + 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, + } diff --git a/src/ecal/calculators/preprocessing_flops.py b/src/ecal/calculators/preprocessing_flops.py new file mode 100644 index 0000000..e843c2a --- /dev/null +++ b/src/ecal/calculators/preprocessing_flops.py @@ -0,0 +1,70 @@ +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) + } diff --git a/src/ecal/calculators/toy_models.py b/src/ecal/calculators/toy_models.py new file mode 100644 index 0000000..d75fbd4 --- /dev/null +++ b/src/ecal/calculators/toy_models.py @@ -0,0 +1,331 @@ +import torch.nn as nn +import torch.nn.functional as F +from efficient_kan import KAN + +import torch +import numpy as np + +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: + x = layer(x) + return self.output(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): + super(SimpleMLP_practical, 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 + + +# --- CNN Model (Modified to vary dense layers) --- +class SimpleCNN_practical(nn.Module): + """ + A simple 1D Convolutional Neural Network (CNN) for sequence or feature vector regression. + """ + def __init__(self, input_size=10, output_size=2, hidden_channels=16, num_layers=3): + super(SimpleCNN_practical, self).__init__() + self.layers = nn.ModuleList() + # Input data is expected to be reshaped to have 1 channel. + current_channels = 1 + + # Create convolutional blocks with increasing channel depth + for i in range(1, num_layers): + out_channels = hidden_channels * (2 ** i) + 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 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: + # x = x.unsqueeze(1) # Add channel dimension if missing + # Expected input x shape: (batch_size, 1, num_features) + for layer in self.layers: + x = layer(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): + def __init__(self, num_layers: int, grid_size: int = 10, din: int = 10, dout: int = 2, k: int = 3): + super(KANLikeRegressor, self).__init__() + self.input_dim = din + + architecture = [din] + [din] * (num_layers) + [dout] + + self.kan = KAN(architecture, grid_size=grid_size, + spline_order=3) + + def forward(self, x): + # Simpler forward pass that processes all layers sequentially + for layer in self.kan.layers: + x = layer(x) + return x + + +#############################TRANSFORMER MODEL FROM SCRATCH############################# + +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 + + # 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 + 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) + 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 + + +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) + # LayerNorm + x = self.layer_norm1(x_attn + x) + # MLP + x_mlp = self.mlp(x) + # LayerNorm + x = self.layer_norm2(x_mlp + 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 + 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': + loss_matrix = loss_matrix.view(B, T) + 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 + self.num_emb = num_emb + self.num_blocks = num_blocks + + # FIX 2: Re-enable the positional embedding layer + self.positional_embedding = nn.Embedding(num_tokens, num_emb) + + # This correctly projects each feature in the sequence to the embedding dimension + self.embedding = nn.Linear(1, num_emb) + + # transformer blocks + self.transformer_blocks = nn.ModuleList() + for _ in range(num_blocks): + self.transformer_blocks.append(TransformerBlock(num_emb=num_emb, num_neurons=num_neurons, num_heads=num_heads)) + + # Output layer for regression (predicting 2 values: target_x, target_y) + regression_output_dim = 1 + self.output_layer = nn.Sequential(nn.Linear(num_emb * num_tokens, regression_output_dim)) + + # dropout layer + self.dropout = nn.Dropout(dropout_prob) + + + def transformer_forward(self, x, causal=True): + # x starts as: B(atch) x T(okens) -> (32, 25) + + # FIX 1: Add a dimension to the input for the linear embedding layer + # x becomes: B x T x 1 -> (32, 25, 1) + x = x.unsqueeze(-1) + + # embedding of tokens + # x becomes B x T x D(im) -> (32, 25, 20) + x = self.embedding(x) + + # embedding of positions + # Create positions tensor: (1, 25) + pos = torch.arange(0, x.shape[1], dtype=torch.long).unsqueeze(0).to(self.device) + # Get positional embeddings: (1, 25, 20) + pos_emb = self.positional_embedding(pos) + + # Add positional embeddings to input embeddings + x = self.dropout(x + pos_emb) + + # transformer blocks + for i in range(self.num_blocks): + x = self.transformer_blocks[i](x, causal=False) # Causal might not be needed for this task + + # Flatten the output of the transformer blocks + # x shape from (B, T, D) to (B, T * D) -> (32, 25 * 20) + x = x.view(x.size(0), -1) + + # output layer + out = self.output_layer(x) + + return out + + def forward(self, x, causal=True): + # This method just calls the main forward pass + return self.transformer_forward(x, causal=causal) diff --git a/src/ecal/calculators/training.py b/src/ecal/calculators/training.py new file mode 100644 index 0000000..cf67ce0 --- /dev/null +++ b/src/ecal/calculators/training.py @@ -0,0 +1,114 @@ +from typing import Dict, Union, 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) -> 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": 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..1489edb --- /dev/null +++ b/src/ecal/calculators/transmission.py @@ -0,0 +1,117 @@ +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 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 + } diff --git a/src/ecal/cli.py b/src/ecal/cli.py new file mode 100644 index 0000000..3223662 --- /dev/null +++ b/src/ecal/cli.py @@ -0,0 +1,136 @@ +"""Command-line interface for eCAL.""" + +import argparse +import json +import sys + +from ecal._version import __version__ + + +def main(argv=None): + 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): + 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(): + 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..39abb80 --- /dev/null +++ b/src/ecal/configs/protocol_configs.py @@ -0,0 +1,260 @@ +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 + ) +} 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..d0a7185 --- /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: 312e12 + tdp_watts: 300 + 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: 990e12 + 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..9edc8eb --- /dev/null +++ b/src/ecal/hardware/profiles.py @@ -0,0 +1,72 @@ +import os +from dataclasses import dataclass +from typing import Dict + +import yaml + + +@dataclass +class HardwareProfile: + """Hardware profile for energy estimation.""" + 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 From 4a1f8aa31c5832798c91d1baa08231ce4ea517d9 Mon Sep 17 00:00:00 2001 From: vh2001 Date: Wed, 15 Jul 2026 08:53:57 +0000 Subject: [PATCH 2/9] added sphix docs and tests --- .gitignore | 1 + .readthedocs.yaml | 17 ++ docs/Makefile | 20 ++ docs/_static/.gitkeep | 0 docs/_templates/.gitkeep | 0 docs/api/index.md | 18 ++ docs/architecture.md | 46 +++++ docs/citation.md | 44 +++++ docs/cli.md | 71 +++++++ docs/conf.py | 57 ++++++ docs/configuration.md | 41 ++++ docs/index.md | 29 +++ docs/installation.md | 25 +++ docs/make.bat | 28 +++ docs/quickstart.md | 47 +++++ pyproject.toml | 6 + src/ecal/api.py | 16 +- src/ecal/calculators/inference.py | 8 +- src/ecal/calculators/model_flops.py | 206 +++++++++++++++++--- src/ecal/calculators/preprocessing.py | 23 ++- src/ecal/calculators/preprocessing_flops.py | 58 ++++++ src/ecal/calculators/training.py | 34 +++- src/ecal/calculators/transmission.py | 47 ++++- src/ecal/cli.py | 20 ++ src/ecal/configs/protocol_configs.py | 40 +++- src/ecal/hardware/profiles.py | 14 +- 26 files changed, 863 insertions(+), 53 deletions(-) create mode 100644 .readthedocs.yaml create mode 100644 docs/Makefile create mode 100644 docs/_static/.gitkeep create mode 100644 docs/_templates/.gitkeep create mode 100644 docs/api/index.md create mode 100644 docs/architecture.md create mode 100644 docs/citation.md create mode 100644 docs/cli.md create mode 100644 docs/conf.py create mode 100644 docs/configuration.md create mode 100644 docs/index.md create mode 100644 docs/installation.md create mode 100644 docs/make.bat create mode 100644 docs/quickstart.md diff --git a/.gitignore b/.gitignore index 805c96b..2aa4533 100644 --- a/.gitignore +++ b/.gitignore @@ -76,6 +76,7 @@ instance/ # Sphinx documentation docs/_build/ +docs/api/generated/ # 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/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..a528fff --- /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 | 300 | 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..07e8f7a --- /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/cfortuna/eCAL/blob/main/LICENSE). + +For contributing/development setup (running tests, linting, type checking), +see the [project README](https://github.com/cfortuna/eCAL#development). diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..3c2f399 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,25 @@ +# Installation + +## From source (recommended for development) + +```bash +git clone https://github.com/cfortuna/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/pyproject.toml b/pyproject.toml index 33228aa..a5c1fe0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,12 @@ dev = [ "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" diff --git a/src/ecal/api.py b/src/ecal/api.py index e281ecb..6d3b993 100644 --- a/src/ecal/api.py +++ b/src/ecal/api.py @@ -53,11 +53,11 @@ def estimate( Args: model_type: Model architecture ("MLP", "CNN", "KAN", "Transformer") model_params: Architecture-specific parameters. Keys depend on model_type: - - MLP: num_layers, din, dout - - CNN: num_cnv_layers, num_pool_layers, i_r, i_c, k_r, k_c, c_in - - KAN: num_layers, grid_size, din, dout - - Transformer: context_length, embedding_size, num_heads, - num_decoder_blocks, feed_forward_size, vocab_size + 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 @@ -78,7 +78,11 @@ def estimate( virtualization_overhead: Energy overhead fraction [0, 1] Returns: - Dictionary with energy breakdown (Joules) and eCAL metric (J/bit) + 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 = {} diff --git a/src/ecal/calculators/inference.py b/src/ecal/calculators/inference.py index 2e96a1e..bd1afa8 100644 --- a/src/ecal/calculators/inference.py +++ b/src/ecal/calculators/inference.py @@ -1,4 +1,4 @@ -from typing import Dict, Union, Tuple, Optional +from typing import Tuple, Optional from ecal.calculators.model_flops import FLOPCalculator, FlopsCalculatorFactory from torchvision.models import resnet18 @@ -36,11 +36,13 @@ def __init__(self, model_name: str, input_size: Tuple, num_samples: int, process 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]]: + def calculate_flops(self) -> float: """ + Calculate total FLOPs for the current inference workload Returns: - Total FLOPs for the current inference + 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 diff --git a/src/ecal/calculators/model_flops.py b/src/ecal/calculators/model_flops.py index e566bb8..7fa6ce5 100644 --- a/src/ecal/calculators/model_flops.py +++ b/src/ecal/calculators/model_flops.py @@ -1,3 +1,7 @@ +"""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 @@ -6,8 +10,25 @@ 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): @@ -17,20 +38,60 @@ def create_calculator(model: Union[nn.Module, str]) -> 'FLOPCalculator': 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, @@ -39,8 +100,19 @@ def calculate(self, model: nn.Module, input_size: Tuple) -> Dict[str, Union[int, 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 @@ -48,13 +120,20 @@ def __init__(self, num_layers: int, din: int, dout: int, num_samples: int = 1, 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 + """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. - Parameters: - - L: Number of layers - - M_l-1: Input dimension of the layer - - M_l: Output dimension of the layer + 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 @@ -73,9 +152,27 @@ def calculate(self, model: nn.Module, input_size: Tuple) -> Dict[str, Union[int, } 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 @@ -92,12 +189,19 @@ def __init__(self, num_cnv_layers: int = 3, num_pool_layers: int = 1, i_r: int = 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 + """Calculate FLOPs and parameters for a Convolutional Neural Network. - Parameters: - - num_cnv_layers: Number of convolutional layers - - num_pool_layers: Number of pooling layers + 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 @@ -143,8 +247,21 @@ def calculate(self, model: nn.Module, input_size: Tuple) -> Dict[str, Union[int, 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 @@ -154,16 +271,23 @@ def __init__(self, num_layers: int, grid_size: int, din: int, dout: int, k: int 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) + """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 @@ -194,8 +318,20 @@ def calculate(self, model: nn.Module, input_size: Tuple) -> Dict[str, Union[int, 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 @@ -204,15 +340,25 @@ def __init__(self, context_length: int, embedding_size: int, num_heads: int, 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 + """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 diff --git a/src/ecal/calculators/preprocessing.py b/src/ecal/calculators/preprocessing.py index 6ba15cf..94726a2 100644 --- a/src/ecal/calculators/preprocessing.py +++ b/src/ecal/calculators/preprocessing.py @@ -1,3 +1,5 @@ +from typing import Dict + from ecal.calculators.preprocessing_flops import ( NormalizationCalculator, MinMaxScalingCalculator, @@ -15,6 +17,13 @@ def __init__(self, preprocessing_type: str = 'normalization', processor_flops_pe 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(), @@ -48,7 +57,19 @@ def calculate_flops(self, data_bits: int, time_steps=1) -> float: return self.calculator.calculate_flops(data_bits) - def calculate_energy(self, data_bits: int, time_steps: int) -> float: + 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) diff --git a/src/ecal/calculators/preprocessing_flops.py b/src/ecal/calculators/preprocessing_flops.py index e843c2a..6869ad0 100644 --- a/src/ecal/calculators/preprocessing_flops.py +++ b/src/ecal/calculators/preprocessing_flops.py @@ -1,15 +1,42 @@ +"""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 @@ -39,7 +66,21 @@ def calculate_flops(self, data_size: int) -> Dict[str, Union[int, Dict]]: 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 @@ -55,7 +96,24 @@ def calculate_flops(self, data_size: int) -> Dict[str, Union[int, Dict]]: 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 diff --git a/src/ecal/calculators/training.py b/src/ecal/calculators/training.py index cf67ce0..bbb2ffa 100644 --- a/src/ecal/calculators/training.py +++ b/src/ecal/calculators/training.py @@ -1,4 +1,4 @@ -from typing import Dict, Union, Tuple, Optional +from typing import Dict, Tuple, Optional from torchvision.models import resnet18 @@ -58,8 +58,18 @@ def __init__(self, model_name: str, 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]]: + 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 @@ -78,6 +88,16 @@ def calculate_flops_training(self) -> Dict[str, Union[int, Dict]]: 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': @@ -92,7 +112,15 @@ def calculate_flops_evaluation(self) -> float: total_flops = forward_flops * evaluation_samples return total_flops - def calculate_energy(self) -> float: + 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() diff --git a/src/ecal/calculators/transmission.py b/src/ecal/calculators/transmission.py index 1489edb..aa8703c 100644 --- a/src/ecal/calculators/transmission.py +++ b/src/ecal/calculators/transmission.py @@ -27,10 +27,28 @@ def __init__(self, physical: str = 'WIFI_PHY', failure_rate: float = 0.0): """ - Initialize calculator with specific protocols for each layer + 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], @@ -46,7 +64,18 @@ def __init__(self, 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 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) @@ -73,7 +102,19 @@ def calculate_layer_energy(self, protocol: LayerProtocol, input_bits: int) -> Di } def calculate_energy(self, data_bits: int) -> Dict[str, Union[float, Dict]]: - """Calculate energy consumption with retransmission consideration""" + """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 diff --git a/src/ecal/cli.py b/src/ecal/cli.py index 3223662..d90843d 100644 --- a/src/ecal/cli.py +++ b/src/ecal/cli.py @@ -8,6 +8,16 @@ 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)", @@ -65,6 +75,15 @@ def main(argv=None): 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 = {} @@ -121,6 +140,7 @@ def _run_estimate(args): def _run_profiles(): + """Print all available hardware profiles as a formatted table.""" from ecal.hardware.profiles import list_profiles profiles = list_profiles() diff --git a/src/ecal/configs/protocol_configs.py b/src/ecal/configs/protocol_configs.py index 39abb80..7479417 100644 --- a/src/ecal/configs/protocol_configs.py +++ b/src/ecal/configs/protocol_configs.py @@ -1,14 +1,42 @@ +"""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""" - 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 + """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 diff --git a/src/ecal/hardware/profiles.py b/src/ecal/hardware/profiles.py index 9edc8eb..eed5f6d 100644 --- a/src/ecal/hardware/profiles.py +++ b/src/ecal/hardware/profiles.py @@ -7,7 +7,19 @@ @dataclass class HardwareProfile: - """Hardware profile for energy estimation.""" + """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 From c7a67fefeed6fd454b1f3997510d534086009af0 Mon Sep 17 00:00:00 2001 From: vh2001 Date: Wed, 15 Jul 2026 10:11:07 +0000 Subject: [PATCH 3/9] fixed small errors in flops and TDP for gpus --- README.md | 2 +- docs/configuration.md | 2 +- src/ecal/hardware/data/profiles.yaml | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5f129a9..15351fa 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ python RunCalculator.py | Profile | FP32 FLOPS | TDP (W) | Device | |--------------------|-------------|---------|--------| | `apple_m2` | 3.6 TFLOPS | 22 | mps | -| `nvidia_a100_80gb` | 19.5 TFLOPS | 300 | cuda | +| `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 | diff --git a/docs/configuration.md b/docs/configuration.md index a528fff..11df9ea 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -20,7 +20,7 @@ used to convert FLOP estimates into energy. They are managed by | Profile | FP32 FLOPS | TDP (W) | Device | |--------------------|-------------|---------|--------| | `apple_m2` | 3.6 TFLOPS | 22 | mps | -| `nvidia_a100_80gb` | 19.5 TFLOPS | 300 | cuda | +| `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 | diff --git a/src/ecal/hardware/data/profiles.yaml b/src/ecal/hardware/data/profiles.yaml index d0a7185..b9dae08 100644 --- a/src/ecal/hardware/data/profiles.yaml +++ b/src/ecal/hardware/data/profiles.yaml @@ -10,8 +10,8 @@ apple_m2: nvidia_a100_80gb: name: "NVIDIA A100 80GB" flops_per_second_fp32: 19.5e12 - flops_per_second_fp16: 312e12 - tdp_watts: 300 + flops_per_second_fp16: 77.97e12 + tdp_watts: 400 gpu_power_watts: 250 idle_power_watts: 50 device: "cuda" @@ -19,7 +19,7 @@ nvidia_a100_80gb: nvidia_h100_sxm: name: "NVIDIA H100 SXM" flops_per_second_fp32: 67e12 - flops_per_second_fp16: 990e12 + flops_per_second_fp16: 267.7e12 tdp_watts: 700 gpu_power_watts: 600 idle_power_watts: 100 From f8afcf08f1193eeb241aff11b455028a44900b9c Mon Sep 17 00:00:00 2001 From: vh2001 Date: Wed, 15 Jul 2026 10:11:15 +0000 Subject: [PATCH 4/9] added examples --- examples/basic_estimate.py | 31 ++++++++++ examples/full_pipeline.py | 124 +++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 examples/basic_estimate.py create mode 100644 examples/full_pipeline.py 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") From 853c9414edbe98bdff6c8f2c1576684c0b3b24ae Mon Sep 17 00:00:00 2001 From: vh2001 Date: Wed, 15 Jul 2026 10:18:12 +0000 Subject: [PATCH 5/9] added github pages for docs --- .github/workflows/docs.yml | 47 ++++++++++++++++++++++++++++++++++++++ README.md | 2 ++ pyproject.toml | 1 + 3 files changed, 50 insertions(+) create mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..d135073 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,47 @@ +name: docs + +on: + push: + branches: [main] + 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: Upload docs artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/_build/html + + deploy: + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + 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/README.md b/README.md index 15351fa..d9dbad5 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ eCAL computes the total energy consumed across the full AI model lifecycle — d Published in **IEEE Journal on Selected Areas in Communications (JSAC), 2026**. +[Documentation](https://vh2001.github.io/eCAL/) + ## Installation ### From source (recommended for development) diff --git a/pyproject.toml b/pyproject.toml index a5c1fe0..15c8a7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ ecal = "ecal.cli:main" [project.urls] Homepage = "https://github.com/cfortuna/eCAL" +Documentation = "https://vh2001.github.io/eCAL/" Paper = "https://ieeexplore.ieee.org/abstract/document/11298182" [tool.setuptools.packages.find] From a19fa9c5c6249d5e4780797fce3cc7318967921e Mon Sep 17 00:00:00 2001 From: vh2001 Date: Wed, 15 Jul 2026 10:23:41 +0000 Subject: [PATCH 6/9] updated for testing on polish-ecal-package --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d135073..4ecc5df 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -32,7 +32,7 @@ jobs: path: docs/_build/html deploy: - if: github.ref == 'refs/heads/main' && github.event_name == 'push' + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') needs: build runs-on: ubuntu-latest permissions: From 33869cf5841df7578346742400e4782ab17b427c Mon Sep 17 00:00:00 2001 From: vh2001 Date: Wed, 15 Jul 2026 10:27:19 +0000 Subject: [PATCH 7/9] updated docs workflow --- .github/workflows/docs.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4ecc5df..0eaeb41 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -2,7 +2,7 @@ name: docs on: push: - branches: [main] + branches: [main, polish-ecal-package] pull_request: workflow_dispatch: @@ -32,7 +32,7 @@ jobs: path: docs/_build/html deploy: - if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') + 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: From a5d9919889deab7c8d64aa650bb08c07b40ca863 Mon Sep 17 00:00:00 2001 From: vh2001 Date: Wed, 15 Jul 2026 13:14:13 +0000 Subject: [PATCH 8/9] prepared for publishing on TestPyPi --- MANIFEST.in | 2 + calculators/ToyModels.py | 355 +++++++++++++++++++++++++++-- pyproject.toml | 6 +- src/ecal/calculators/toy_models.py | 331 --------------------------- 4 files changed, 336 insertions(+), 358 deletions(-) create mode 100644 MANIFEST.in delete mode 100644 src/ecal/calculators/toy_models.py 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/calculators/ToyModels.py b/calculators/ToyModels.py index ab2f27e..d75fbd4 100644 --- a/calculators/ToyModels.py +++ b/calculators/ToyModels.py @@ -1,24 +1,331 @@ -"""Backward-compatibility shim — use ecal.calculators.toy_models instead.""" -from ecal.calculators.toy_models import ( # noqa: F401 - SimpleMLP, - SimpleCNN, - SimpleMLP_practical, - SimpleCNN_practical, - KANLikeRegressor, - MultiHeadSelfAttention, - TransformerBlock, - LossFun, - Transformer, -) - -__all__ = [ - "SimpleMLP", - "SimpleCNN", - "SimpleMLP_practical", - "SimpleCNN_practical", - "KANLikeRegressor", - "MultiHeadSelfAttention", - "TransformerBlock", - "LossFun", - "Transformer", -] +import torch.nn as nn +import torch.nn.functional as F +from efficient_kan import KAN + +import torch +import numpy as np + +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: + x = layer(x) + return self.output(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): + super(SimpleMLP_practical, 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 + + +# --- CNN Model (Modified to vary dense layers) --- +class SimpleCNN_practical(nn.Module): + """ + A simple 1D Convolutional Neural Network (CNN) for sequence or feature vector regression. + """ + def __init__(self, input_size=10, output_size=2, hidden_channels=16, num_layers=3): + super(SimpleCNN_practical, self).__init__() + self.layers = nn.ModuleList() + # Input data is expected to be reshaped to have 1 channel. + current_channels = 1 + + # Create convolutional blocks with increasing channel depth + for i in range(1, num_layers): + out_channels = hidden_channels * (2 ** i) + 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 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: + # x = x.unsqueeze(1) # Add channel dimension if missing + # Expected input x shape: (batch_size, 1, num_features) + for layer in self.layers: + x = layer(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): + def __init__(self, num_layers: int, grid_size: int = 10, din: int = 10, dout: int = 2, k: int = 3): + super(KANLikeRegressor, self).__init__() + self.input_dim = din + + architecture = [din] + [din] * (num_layers) + [dout] + + self.kan = KAN(architecture, grid_size=grid_size, + spline_order=3) + + def forward(self, x): + # Simpler forward pass that processes all layers sequentially + for layer in self.kan.layers: + x = layer(x) + return x + + +#############################TRANSFORMER MODEL FROM SCRATCH############################# + +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 + + # 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 + 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) + 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 + + +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) + # LayerNorm + x = self.layer_norm1(x_attn + x) + # MLP + x_mlp = self.mlp(x) + # LayerNorm + x = self.layer_norm2(x_mlp + 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 + 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': + loss_matrix = loss_matrix.view(B, T) + 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 + self.num_emb = num_emb + self.num_blocks = num_blocks + + # FIX 2: Re-enable the positional embedding layer + self.positional_embedding = nn.Embedding(num_tokens, num_emb) + + # This correctly projects each feature in the sequence to the embedding dimension + self.embedding = nn.Linear(1, num_emb) + + # transformer blocks + self.transformer_blocks = nn.ModuleList() + for _ in range(num_blocks): + self.transformer_blocks.append(TransformerBlock(num_emb=num_emb, num_neurons=num_neurons, num_heads=num_heads)) + + # Output layer for regression (predicting 2 values: target_x, target_y) + regression_output_dim = 1 + self.output_layer = nn.Sequential(nn.Linear(num_emb * num_tokens, regression_output_dim)) + + # dropout layer + self.dropout = nn.Dropout(dropout_prob) + + + def transformer_forward(self, x, causal=True): + # x starts as: B(atch) x T(okens) -> (32, 25) + + # FIX 1: Add a dimension to the input for the linear embedding layer + # x becomes: B x T x 1 -> (32, 25, 1) + x = x.unsqueeze(-1) + + # embedding of tokens + # x becomes B x T x D(im) -> (32, 25, 20) + x = self.embedding(x) + + # embedding of positions + # Create positions tensor: (1, 25) + pos = torch.arange(0, x.shape[1], dtype=torch.long).unsqueeze(0).to(self.device) + # Get positional embeddings: (1, 25, 20) + pos_emb = self.positional_embedding(pos) + + # Add positional embeddings to input embeddings + x = self.dropout(x + pos_emb) + + # transformer blocks + for i in range(self.num_blocks): + x = self.transformer_blocks[i](x, causal=False) # Causal might not be needed for this task + + # Flatten the output of the transformer blocks + # x shape from (B, T, D) to (B, T * D) -> (32, 25 * 20) + x = x.view(x.size(0), -1) + + # output layer + out = self.output_layer(x) + + return out + + def forward(self, x, causal=True): + # This method just calls the main forward pass + return self.transformer_forward(x, causal=causal) diff --git a/pyproject.toml b/pyproject.toml index 15c8a7b..edbc8bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=68.0", "setuptools-scm>=8.0"] +requires = ["setuptools>=68.0"] build-backend = "setuptools.build_meta" [project] @@ -10,7 +10,7 @@ readme = "README.md" license = "BSD-3-Clause" requires-python = ">=3.9" authors = [ - {name = "SensorLab"}, + {name = "SensorLab", email = "sensorlab.jsi@gmail.com"}, ] keywords = ["energy", "AI", "lifecycle", "sustainability", "eCAL"] classifiers = [ @@ -56,7 +56,7 @@ docs = [ ecal = "ecal.cli:main" [project.urls] -Homepage = "https://github.com/cfortuna/eCAL" +Homepage = "https://github.com/sensorlab/eCAL" Documentation = "https://vh2001.github.io/eCAL/" Paper = "https://ieeexplore.ieee.org/abstract/document/11298182" diff --git a/src/ecal/calculators/toy_models.py b/src/ecal/calculators/toy_models.py deleted file mode 100644 index d75fbd4..0000000 --- a/src/ecal/calculators/toy_models.py +++ /dev/null @@ -1,331 +0,0 @@ -import torch.nn as nn -import torch.nn.functional as F -from efficient_kan import KAN - -import torch -import numpy as np - -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: - x = layer(x) - return self.output(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): - super(SimpleMLP_practical, 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 - - -# --- CNN Model (Modified to vary dense layers) --- -class SimpleCNN_practical(nn.Module): - """ - A simple 1D Convolutional Neural Network (CNN) for sequence or feature vector regression. - """ - def __init__(self, input_size=10, output_size=2, hidden_channels=16, num_layers=3): - super(SimpleCNN_practical, self).__init__() - self.layers = nn.ModuleList() - # Input data is expected to be reshaped to have 1 channel. - current_channels = 1 - - # Create convolutional blocks with increasing channel depth - for i in range(1, num_layers): - out_channels = hidden_channels * (2 ** i) - 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 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: - # x = x.unsqueeze(1) # Add channel dimension if missing - # Expected input x shape: (batch_size, 1, num_features) - for layer in self.layers: - x = layer(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): - def __init__(self, num_layers: int, grid_size: int = 10, din: int = 10, dout: int = 2, k: int = 3): - super(KANLikeRegressor, self).__init__() - self.input_dim = din - - architecture = [din] + [din] * (num_layers) + [dout] - - self.kan = KAN(architecture, grid_size=grid_size, - spline_order=3) - - def forward(self, x): - # Simpler forward pass that processes all layers sequentially - for layer in self.kan.layers: - x = layer(x) - return x - - -#############################TRANSFORMER MODEL FROM SCRATCH############################# - -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 - - # 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 - 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) - 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 - - -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) - # LayerNorm - x = self.layer_norm1(x_attn + x) - # MLP - x_mlp = self.mlp(x) - # LayerNorm - x = self.layer_norm2(x_mlp + 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 - 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': - loss_matrix = loss_matrix.view(B, T) - 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 - self.num_emb = num_emb - self.num_blocks = num_blocks - - # FIX 2: Re-enable the positional embedding layer - self.positional_embedding = nn.Embedding(num_tokens, num_emb) - - # This correctly projects each feature in the sequence to the embedding dimension - self.embedding = nn.Linear(1, num_emb) - - # transformer blocks - self.transformer_blocks = nn.ModuleList() - for _ in range(num_blocks): - self.transformer_blocks.append(TransformerBlock(num_emb=num_emb, num_neurons=num_neurons, num_heads=num_heads)) - - # Output layer for regression (predicting 2 values: target_x, target_y) - regression_output_dim = 1 - self.output_layer = nn.Sequential(nn.Linear(num_emb * num_tokens, regression_output_dim)) - - # dropout layer - self.dropout = nn.Dropout(dropout_prob) - - - def transformer_forward(self, x, causal=True): - # x starts as: B(atch) x T(okens) -> (32, 25) - - # FIX 1: Add a dimension to the input for the linear embedding layer - # x becomes: B x T x 1 -> (32, 25, 1) - x = x.unsqueeze(-1) - - # embedding of tokens - # x becomes B x T x D(im) -> (32, 25, 20) - x = self.embedding(x) - - # embedding of positions - # Create positions tensor: (1, 25) - pos = torch.arange(0, x.shape[1], dtype=torch.long).unsqueeze(0).to(self.device) - # Get positional embeddings: (1, 25, 20) - pos_emb = self.positional_embedding(pos) - - # Add positional embeddings to input embeddings - x = self.dropout(x + pos_emb) - - # transformer blocks - for i in range(self.num_blocks): - x = self.transformer_blocks[i](x, causal=False) # Causal might not be needed for this task - - # Flatten the output of the transformer blocks - # x shape from (B, T, D) to (B, T * D) -> (32, 25 * 20) - x = x.view(x.size(0), -1) - - # output layer - out = self.output_layer(x) - - return out - - def forward(self, x, causal=True): - # This method just calls the main forward pass - return self.transformer_forward(x, causal=causal) From 2477ac5c5409a115ed3c8d0b08dfe65e9b356df1 Mon Sep 17 00:00:00 2001 From: vh2001 Date: Fri, 17 Jul 2026 09:54:20 +0000 Subject: [PATCH 9/9] added eCAL website --- .github/workflows/docs.yml | 12 +- .github/workflows/publish.yml | 57 +++ .gitignore | 3 + README.md | 4 +- docs/index.md | 4 +- docs/installation.md | 2 +- pyproject.toml | 2 +- website/index.html | 730 ++++++++++++++++++++++++++++++++++ 8 files changed, 806 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 website/index.html diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0eaeb41..144e89b 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -26,10 +26,18 @@ jobs: - name: Build Sphinx docs run: sphinx-build -b html -W --keep-going docs docs/_build/html - - name: Upload docs artifact + - 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: docs/_build/html + 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')) 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 2aa4533..5fe04d1 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,9 @@ instance/ docs/_build/ docs/api/generated/ +# Combined GitHub Pages assembly (website + docs) +/site/ + # PyBuilder .pybuilder/ target/ diff --git a/README.md b/README.md index d9dbad5..536233b 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,14 @@ eCAL computes the total energy consumed across the full AI model lifecycle — d Published in **IEEE Journal on Selected Areas in Communications (JSAC), 2026**. -[Documentation](https://vh2001.github.io/eCAL/) +[Documentation](https://sensorlab.github.io/eCAL/docs/) ## Installation ### From source (recommended for development) ```bash -git clone https://github.com/cfortuna/eCAL.git +git clone https://github.com/sensorlab/eCAL.git cd eCAL pip install -e ".[dev]" ``` diff --git a/docs/index.md b/docs/index.md index 07e8f7a..6fc1100 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,7 +23,7 @@ api/index ## License -BSD 3-Clause License. See [LICENSE](https://github.com/cfortuna/eCAL/blob/main/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/cfortuna/eCAL#development). +see the [project README](https://github.com/sensorlab/eCAL#development). diff --git a/docs/installation.md b/docs/installation.md index 3c2f399..ec202fb 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -3,7 +3,7 @@ ## From source (recommended for development) ```bash -git clone https://github.com/cfortuna/eCAL.git +git clone https://github.com/sensorlab/eCAL.git cd eCAL pip install -e ".[dev]" ``` diff --git a/pyproject.toml b/pyproject.toml index edbc8bb..664abc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ ecal = "ecal.cli:main" [project.urls] Homepage = "https://github.com/sensorlab/eCAL" -Documentation = "https://vh2001.github.io/eCAL/" +Documentation = "https://sensorlab.github.io/eCAL/docs/" Paper = "https://ieeexplore.ieee.org/abstract/document/11298182" [tool.setuptools.packages.find] 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 @@ + + + + + +eCAL — The Energy Cost of AI Lifecycle + + + + + + + + + + + + + + + + +
+ +
+ + +
+
+ Open-source · IEEE JSAC 2026 +

The energy cost of building intelligence.

+

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.

+ + +
+
+
+ 1 Set up the lifecycle + 2 Plot the case study + 3 Draw the real data +
+ +
Every AI model pays a one-time development cost before its first inference.
+
+ +
Computed directly with the open-source eCAL calculator (ecal-energy) — the same case-study configuration and the same sampled inference counts (γ = 10²…10⁸) as the paper's Fig. 13.
+
+ +
+
2.73×
Lower eCAL going from 100 to 10,000 inferences on the case-study MLP
+
141×
Higher energy per bit for a Transformer vs. an MLP at 100 inferences
+
7
OSI layers modeled end-to-end, from application down to physical
+
4
Model architectures supported out of the box — MLP, CNN, KAN, Transformer
+
+
+
+ +
+
+
+ The problem +

Existing energy metrics only see part of the picture.

+

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.

+
+
+
+
+

Blind to computation

+

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.

+
+
+
+

Blind to the network

+

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.

+
+
+
+

No end-to-end view

+

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.

+
+
+
+
+ +
+
+
+ What we build +

A single metric, grounded in both networking and machine learning.

+

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.

+
+
+
01

Lifecycle-wide methodology

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.

+
02

OSI-grounded data collection

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.

+
03

Closed-form FLOPs, any architecture

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.

+
04

One open-source calculator

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.

+
+
+
+ +
+
+
+ The metric +

One equation for the whole AI lifecycle.

+

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.

+
+
+
+
+
+ eCAL= + + eCALabs + f · IS · (NS + γ·NI,P) + +
+
+ eCALabs=(1 + γᵥ)·(ED+γ·Einf,p) +
+
+

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.

+
+
+
γ smallDevelopment-dominateda single inference (6.57×10⁻⁷ J/b) is far cheaper than development (2.41×10⁻⁶ J/b) alone.
+
γ largeInference-dominateddevelopment cost is amortized — 2.73× lower eCAL at γ=1000 than at γ=100 for the case-study MLP.
+
γᵥ > 0Virtualization compounds botha flat (1+γᵥ) overhead multiplier, regardless of regime.
+

Validated against real Wi-Fi 6 and 5G measurement datasets, where eCAL acts as a tight upper bound on observed energy cost.

+
+
+
+
+ +
+
+
+ Peer-reviewed research +

The work behind the metric.

+

Grounded in a formal lifecycle methodology, validated against real network measurement + studies, and open-sourced as a working tool — not just a paper.

+
+
+ +
IEEE JSAC · 2026★ Flagship paper
+

The Energy Cost of Artificial Intelligence Lifecycle in Communication Networks

+

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.

+
    +
  • Proposes eCAL, the first metric spanning the full AI lifecycle in J/bit
  • +
  • Shows a 2.73× energy-efficiency improvement going from 100 to 1000 inferences on a case-study MLP
  • +
  • Validated against real Wi-Fi 6 and 5G measurement datasets, acting as a tight upper bound
  • +
  • Ships as an open-source, modular, extensible calculator (PyPI ecal-energy)
  • +
+
Vol. 44, pp. 2427–2443 · Published 11 Dec 2025 · Chou, Hribar, Hanžel, Mohorčič, Fortuna — SensorLab, Jožef Stefan Institute
+
+ + 2025 IEEE NFV-SDN +

Energy Cost of the AI/ML Workflow in O-RAN

+

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.

+
    +
  • Extends the eCAL methodology to O-RAN AI/ML pipelines
  • +
  • Analyzes energy cost across the workflow, not just training
  • +
  • Contributes toward sustainable 6G network design
  • +
+
pp. 1–6 · Chou, Hribar, Bertalanič, Mohorčič, Lagkas, Sarigiannidis, Fortuna
+
+ + 2024 IEEE CSCN +

Towards the Standardization of Energy Efficiency Metrics of the AI Lifecycle in 6G and Beyond

+

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.

+
    +
  • Motivates the need for standardized AI-lifecycle energy metrics
  • +
  • Frames energy efficiency as a first-class 6G design requirement
  • +
  • Lays the conceptual groundwork later formalized as eCAL
  • +
+
pp. 187–190 · Chou, Hribar, Mohorčič, Fortuna
+
+
+
+
+ +
+
+
+ Try it +

Estimate the energy cost of your own model.

+

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.

+
+
+
+
Install & CLI
+
$ pip install ecal-energy
+$ ecal estimate --model MLP --layers 3 --epochs 50 --hardware apple_m2
+
+
+
Python API
+
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"])
+
+
+
+ Models — MLP · CNN · KAN · Transformer + Hardware — Apple M2 (3.6 TFLOPS/22W) · A100 80GB (19.5 TFLOPS/400W) · H100 SXM (67 TFLOPS/700W) · generic CPU (1 TFLOPS/100W) · generic edge (0.01 TFLOPS/15W) +
+ +
BSD-3-Clause licensed · Available on PyPI as ecal-energy
+
+
+ +
+
+
+ Why eCAL +

Scaling laws gave training a shared unit of cost. eCAL gives it to the whole lifecycle.

+
+
+

See the true cost before you deploy

Estimate the full lifecycle cost of a model — not just training, not just inference — before committing hardware or bandwidth to it.

+

Compare architectures fairly

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.

+

Extend it to your own stack

Open-source and modular: plug in custom protocols, architectures, or hardware profiles instead of being limited to what ships out of the box.

+
+
+
+ +
+
+
+ Get in touch +

Let's make AI's energy cost measurable.

+

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.

+ +
+
+
+ + + + + +