e2am 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- e2am/__init__.py +45 -0
- e2am/cli/__init__.py +7 -0
- e2am/cli/loader.py +60 -0
- e2am/cli/main.py +334 -0
- e2am/config/__init__.py +21 -0
- e2am/config/settings.py +163 -0
- e2am/exceptions.py +45 -0
- e2am/integrations/__init__.py +22 -0
- e2am/integrations/huggingface.py +168 -0
- e2am/metrics/__init__.py +56 -0
- e2am/metrics/classification.py +124 -0
- e2am/metrics/green.py +153 -0
- e2am/metrics/tracker.py +99 -0
- e2am/monitoring/__init__.py +42 -0
- e2am/monitoring/api.py +167 -0
- e2am/monitoring/carbon.py +108 -0
- e2am/monitoring/energy.py +79 -0
- e2am/monitoring/result.py +137 -0
- e2am/monitoring/samplers.py +228 -0
- e2am/monitoring/session.py +263 -0
- e2am/optimize/__init__.py +8 -0
- e2am/optimize/analyzer.py +225 -0
- e2am/optimize/suggestions.py +35 -0
- e2am/plugins/__init__.py +23 -0
- e2am/plugins/base.py +48 -0
- e2am/plugins/mlflow.py +84 -0
- e2am/plugins/tensorboard.py +59 -0
- e2am/plugins/wandb.py +72 -0
- e2am/plugins/webhooks.py +98 -0
- e2am/profiler/__init__.py +21 -0
- e2am/profiler/flops.py +238 -0
- e2am/profiler/latency.py +138 -0
- e2am/profiler/memory.py +63 -0
- e2am/py.typed +0 -0
- e2am/reports/__init__.py +17 -0
- e2am/reports/common.py +158 -0
- e2am/reports/dashboard.py +121 -0
- e2am/reports/generate.py +82 -0
- e2am/reports/html.py +108 -0
- e2am/reports/leaderboard.py +65 -0
- e2am/reports/markdown.py +49 -0
- e2am/reports/pdf.py +112 -0
- e2am/trainer/__init__.py +33 -0
- e2am/trainer/callbacks.py +135 -0
- e2am/trainer/result.py +115 -0
- e2am/trainer/trainer.py +440 -0
- e2am/utils/__init__.py +22 -0
- e2am/utils/hardware.py +221 -0
- e2am/utils/logging.py +65 -0
- e2am/utils/nvml.py +178 -0
- e2am/visualization/__init__.py +15 -0
- e2am/visualization/plots.py +272 -0
- e2am/visualization/style.py +44 -0
- e2am-0.1.0.dist-info/METADATA +273 -0
- e2am-0.1.0.dist-info/RECORD +58 -0
- e2am-0.1.0.dist-info/WHEEL +4 -0
- e2am-0.1.0.dist-info/entry_points.txt +2 -0
- e2am-0.1.0.dist-info/licenses/LICENSE +21 -0
e2am/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""E2AM — Energy Efficient AI Models.
|
|
2
|
+
|
|
3
|
+
Automatic energy, carbon, and performance profiling for AI training with
|
|
4
|
+
almost zero code changes.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
>>> from e2am import monitor
|
|
8
|
+
>>> with monitor(project="ResNet50"):
|
|
9
|
+
... train()
|
|
10
|
+
|
|
11
|
+
>>> from e2am import Trainer
|
|
12
|
+
>>> trainer = Trainer(model=model, optimizer=optimizer,
|
|
13
|
+
... train_loader=train_loader, val_loader=val_loader)
|
|
14
|
+
>>> trainer.fit()
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = ["Trainer", "__version__", "monitor"]
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING: # pragma: no cover - import-time only for type checkers
|
|
26
|
+
from e2am.monitoring import monitor
|
|
27
|
+
from e2am.trainer import Trainer
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def __getattr__(name: str) -> Any:
|
|
31
|
+
"""Lazily resolve the public API.
|
|
32
|
+
|
|
33
|
+
Keeps ``import e2am`` fast and torch-free: the ``Trainer`` (which needs
|
|
34
|
+
PyTorch) is only imported when actually accessed, so monitoring-only
|
|
35
|
+
installs work on machines without PyTorch.
|
|
36
|
+
"""
|
|
37
|
+
if name == "monitor":
|
|
38
|
+
from e2am.monitoring import monitor
|
|
39
|
+
|
|
40
|
+
return monitor
|
|
41
|
+
if name == "Trainer":
|
|
42
|
+
from e2am.trainer import Trainer
|
|
43
|
+
|
|
44
|
+
return Trainer
|
|
45
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
e2am/cli/__init__.py
ADDED
e2am/cli/loader.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Loading user model scripts for the CLI.
|
|
2
|
+
|
|
3
|
+
``e2am train`` and ``e2am benchmark`` operate on a plain Python file that
|
|
4
|
+
exports well-known factory functions — no subclassing, no registration:
|
|
5
|
+
|
|
6
|
+
* ``get_model() -> nn.Module`` (required)
|
|
7
|
+
* ``get_loaders() -> (train_loader, val_loader | None)`` (train only)
|
|
8
|
+
* ``get_optimizer(model) -> torch.optim.Optimizer`` (optional; Adam 1e-3)
|
|
9
|
+
* ``get_loss() -> callable`` (optional; CrossEntropyLoss)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib.util
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from types import ModuleType
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from e2am.exceptions import ConfigError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_user_module(path: str | Path) -> ModuleType:
|
|
24
|
+
"""Import a user's Python file as a module.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
ConfigError: If the file is missing or fails to import.
|
|
28
|
+
"""
|
|
29
|
+
path = Path(path)
|
|
30
|
+
if not path.exists():
|
|
31
|
+
raise ConfigError(f"Script not found: {path}")
|
|
32
|
+
spec = importlib.util.spec_from_file_location(f"e2am_user_{path.stem}", path)
|
|
33
|
+
if spec is None or spec.loader is None:
|
|
34
|
+
raise ConfigError(f"Cannot import {path} as a Python module.")
|
|
35
|
+
module = importlib.util.module_from_spec(spec)
|
|
36
|
+
sys.modules[spec.name] = module
|
|
37
|
+
try:
|
|
38
|
+
spec.loader.exec_module(module)
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
raise ConfigError(f"Error while importing {path}: {exc}") from exc
|
|
41
|
+
return module
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_factory(module: ModuleType, name: str, required: bool = True) -> Any:
|
|
45
|
+
"""Fetch a factory function from the user module.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
ConfigError: If a required factory is missing or not callable.
|
|
49
|
+
"""
|
|
50
|
+
factory = getattr(module, name, None)
|
|
51
|
+
if factory is None:
|
|
52
|
+
if required:
|
|
53
|
+
raise ConfigError(
|
|
54
|
+
f"{module.__name__} must define `{name}()` "
|
|
55
|
+
f"(see the docs for the e2am script convention)."
|
|
56
|
+
)
|
|
57
|
+
return None
|
|
58
|
+
if not callable(factory):
|
|
59
|
+
raise ConfigError(f"`{name}` in the script is not callable.")
|
|
60
|
+
return factory
|
e2am/cli/main.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""The ``e2am`` command line interface.
|
|
2
|
+
|
|
3
|
+
Commands mirror the Python API and share its code paths — the CLI is a thin
|
|
4
|
+
orchestration layer, never a second implementation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
import e2am
|
|
18
|
+
from e2am.config.settings import ExperimentConfig
|
|
19
|
+
from e2am.exceptions import E2AMError
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
22
|
+
from e2am.monitoring.result import MonitorResult
|
|
23
|
+
from e2am.trainer.result import TrainingResult
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
name="e2am",
|
|
27
|
+
help="E2AM — Energy Efficient AI Models: automatic energy, carbon, and "
|
|
28
|
+
"performance profiling for AI training.",
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
pretty_exceptions_show_locals=False,
|
|
31
|
+
)
|
|
32
|
+
console = Console()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _fail(message: str) -> None:
|
|
36
|
+
console.print(f"[red]error:[/red] {message}")
|
|
37
|
+
raise typer.Exit(code=1)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command()
|
|
41
|
+
def version() -> None:
|
|
42
|
+
"""Print the E2AM version."""
|
|
43
|
+
console.print(f"e2am {e2am.__version__}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.command()
|
|
47
|
+
def hardware(
|
|
48
|
+
as_json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Detect hardware and its energy-measurement capabilities."""
|
|
51
|
+
from e2am.utils.hardware import detect_hardware
|
|
52
|
+
|
|
53
|
+
info = detect_hardware()
|
|
54
|
+
if as_json:
|
|
55
|
+
console.print_json(info.model_dump_json())
|
|
56
|
+
return
|
|
57
|
+
table = Table(title="Detected hardware", title_style="bold green")
|
|
58
|
+
table.add_column("Component", style="bold")
|
|
59
|
+
table.add_column("Details")
|
|
60
|
+
table.add_row("Host", f"{info.hostname} · {info.os} {info.os_version}")
|
|
61
|
+
table.add_row("Python", info.python_version)
|
|
62
|
+
table.add_row(
|
|
63
|
+
"CPU",
|
|
64
|
+
f"{info.cpu.model}\n"
|
|
65
|
+
f"{info.cpu.physical_cores or '?'} cores / {info.cpu.logical_cores or '?'} threads · "
|
|
66
|
+
f"TDP {info.cpu.tdp_w:.0f} W (estimation basis)",
|
|
67
|
+
)
|
|
68
|
+
table.add_row(
|
|
69
|
+
"RAM", f"{info.ram.total_gb:.1f} GB · ~{info.ram.estimated_power_w:.1f} W estimated"
|
|
70
|
+
)
|
|
71
|
+
for gpu in info.gpus:
|
|
72
|
+
sensor = (
|
|
73
|
+
"[green]live power sensor[/green]"
|
|
74
|
+
if gpu.supports_power_reading
|
|
75
|
+
else f"[yellow]no power sensor — estimating at {gpu.tdp_w:.0f} W × util[/yellow]"
|
|
76
|
+
)
|
|
77
|
+
table.add_row(
|
|
78
|
+
f"GPU {gpu.index}",
|
|
79
|
+
f"{gpu.name} · {gpu.total_memory_mb or 0:.0f} MB · "
|
|
80
|
+
f"driver {gpu.driver_version or '?'}\n{sensor}",
|
|
81
|
+
)
|
|
82
|
+
if info.torch_version:
|
|
83
|
+
cuda = f"CUDA {info.cuda_version}" if info.cuda_available else "CPU-only build"
|
|
84
|
+
table.add_row("PyTorch", f"{info.torch_version} · {cuda}")
|
|
85
|
+
else:
|
|
86
|
+
table.add_row("PyTorch", "[yellow]not installed[/yellow] (monitoring still works)")
|
|
87
|
+
console.print(table)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.command()
|
|
91
|
+
def train(
|
|
92
|
+
script: Path = typer.Argument(..., help="Python file defining get_model() and get_loaders()."),
|
|
93
|
+
config: Path | None = typer.Option(None, "--config", "-c", help="Experiment config YAML."),
|
|
94
|
+
epochs: int | None = typer.Option(None, help="Override epochs."),
|
|
95
|
+
device: str | None = typer.Option(None, help="Override device (cuda/cpu)."),
|
|
96
|
+
run_name: str | None = typer.Option(None, help="Override run name."),
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Train a model from a script with full E2AM telemetry."""
|
|
99
|
+
from e2am.cli.loader import get_factory, load_user_module
|
|
100
|
+
from e2am.trainer import Trainer
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
module = load_user_module(script)
|
|
104
|
+
get_model = get_factory(module, "get_model")
|
|
105
|
+
get_loaders = get_factory(module, "get_loaders")
|
|
106
|
+
get_optimizer = get_factory(module, "get_optimizer", required=False)
|
|
107
|
+
get_loss = get_factory(module, "get_loss", required=False)
|
|
108
|
+
|
|
109
|
+
experiment = ExperimentConfig.from_yaml(config) if config else ExperimentConfig()
|
|
110
|
+
model = get_model()
|
|
111
|
+
loaders = get_loaders()
|
|
112
|
+
train_loader, val_loader = loaders if isinstance(loaders, tuple) else (loaders, None)
|
|
113
|
+
if get_optimizer is not None:
|
|
114
|
+
optimizer = get_optimizer(model)
|
|
115
|
+
else:
|
|
116
|
+
import torch
|
|
117
|
+
|
|
118
|
+
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
|
|
119
|
+
trainer = Trainer(
|
|
120
|
+
model=model,
|
|
121
|
+
optimizer=optimizer,
|
|
122
|
+
train_loader=train_loader,
|
|
123
|
+
val_loader=val_loader,
|
|
124
|
+
loss_fn=get_loss() if get_loss is not None else None,
|
|
125
|
+
config=experiment,
|
|
126
|
+
epochs=epochs,
|
|
127
|
+
device=device,
|
|
128
|
+
run_name=run_name,
|
|
129
|
+
)
|
|
130
|
+
result = trainer.fit()
|
|
131
|
+
except E2AMError as exc:
|
|
132
|
+
_fail(str(exc))
|
|
133
|
+
return
|
|
134
|
+
console.print(
|
|
135
|
+
f"[green]Run complete:[/green] {result.run_name} · status {result.status} · "
|
|
136
|
+
f"{result.total_energy_wh or 0:.4g} Wh · artifacts in "
|
|
137
|
+
f"{experiment.output.dir / result.run_name}"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@app.command()
|
|
142
|
+
def benchmark(
|
|
143
|
+
script: Path = typer.Argument(..., help="Python file defining get_model()."),
|
|
144
|
+
input_size: str = typer.Option(
|
|
145
|
+
..., "--input-size", "-i", help="Input shape incl. batch, e.g. 8,3,224,224"
|
|
146
|
+
),
|
|
147
|
+
device: str | None = typer.Option(None, help="Device (default: auto)."),
|
|
148
|
+
iterations: int = typer.Option(50, help="Timed iterations."),
|
|
149
|
+
warmup: int = typer.Option(10, help="Warmup iterations."),
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Profile FLOPs, latency, throughput, and energy per inference."""
|
|
152
|
+
import torch
|
|
153
|
+
|
|
154
|
+
from e2am.cli.loader import get_factory, load_user_module
|
|
155
|
+
from e2am.monitoring.session import MonitorSession
|
|
156
|
+
from e2am.profiler import benchmark_latency, profile_model
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
shape = tuple(int(part) for part in input_size.replace(" ", "").split(","))
|
|
160
|
+
except ValueError:
|
|
161
|
+
_fail(f"--input-size must be comma-separated integers, got {input_size!r}")
|
|
162
|
+
return
|
|
163
|
+
try:
|
|
164
|
+
module = load_user_module(script)
|
|
165
|
+
model = get_factory(module, "get_model")()
|
|
166
|
+
resolved = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
|
|
167
|
+
model = model.to(resolved)
|
|
168
|
+
|
|
169
|
+
profile = profile_model(model, input_size=shape, device=resolved)
|
|
170
|
+
session = MonitorSession(project="benchmark", run_name=script.stem)
|
|
171
|
+
session.config.sampling_interval_s = 0.2
|
|
172
|
+
session.start()
|
|
173
|
+
latency = benchmark_latency(
|
|
174
|
+
model, input_size=shape, device=resolved, warmup=warmup, iterations=iterations
|
|
175
|
+
)
|
|
176
|
+
monitor_result = session.stop()
|
|
177
|
+
except E2AMError as exc:
|
|
178
|
+
_fail(str(exc))
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
energy_j = monitor_result.total_energy_j
|
|
182
|
+
per_inference_j = energy_j / iterations if iterations else 0.0
|
|
183
|
+
|
|
184
|
+
table = Table(
|
|
185
|
+
title=f"Benchmark · {profile.model_name} @ {list(shape)} on {latency.device}",
|
|
186
|
+
title_style="bold green",
|
|
187
|
+
)
|
|
188
|
+
table.add_column("Metric", style="bold")
|
|
189
|
+
table.add_column("Value", justify="right")
|
|
190
|
+
table.add_row("Parameters", f"{profile.params:,}")
|
|
191
|
+
table.add_row("MACs / forward", f"{profile.macs:,} ({profile.gmacs:.3f} G)")
|
|
192
|
+
table.add_row("FLOPs / forward", f"{profile.flops:,} ({profile.gflops:.3f} G)")
|
|
193
|
+
table.add_row("Model size", f"{profile.model_size_mb:.2f} MB")
|
|
194
|
+
if profile.coverage < 1.0:
|
|
195
|
+
table.add_row("MAC coverage", f"[yellow]{profile.coverage * 100:.0f} %[/yellow]")
|
|
196
|
+
table.add_row("Latency mean", f"{latency.mean_ms:.3f} ms")
|
|
197
|
+
table.add_row("Latency p50 / p95", f"{latency.p50_ms:.3f} / {latency.p95_ms:.3f} ms")
|
|
198
|
+
table.add_row("Throughput", f"{latency.throughput_samples_per_s:.1f} samples/s")
|
|
199
|
+
table.add_row("Energy (whole benchmark)", f"{monitor_result.total_energy_wh:.5f} Wh")
|
|
200
|
+
table.add_row("Energy / inference", f"{per_inference_j:.4f} J")
|
|
201
|
+
console.print(table)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _load_result(run_dir: Path) -> TrainingResult | MonitorResult:
|
|
205
|
+
from e2am.monitoring.result import MonitorResult
|
|
206
|
+
from e2am.trainer.result import TrainingResult
|
|
207
|
+
|
|
208
|
+
metrics = run_dir / "metrics.json"
|
|
209
|
+
if not metrics.exists():
|
|
210
|
+
_fail(f"No metrics.json in {run_dir}")
|
|
211
|
+
data = json.loads(metrics.read_text(encoding="utf-8"))
|
|
212
|
+
if "epochs_requested" in data:
|
|
213
|
+
return TrainingResult.model_validate(data)
|
|
214
|
+
return MonitorResult.model_validate(data)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.command()
|
|
218
|
+
def report(
|
|
219
|
+
run_dir: Path = typer.Argument(..., help="A run directory containing metrics.json."),
|
|
220
|
+
pdf: bool = typer.Option(False, "--pdf", help="Also generate report.pdf."),
|
|
221
|
+
) -> None:
|
|
222
|
+
"""(Re)generate plots and reports for a finished run."""
|
|
223
|
+
from e2am.config.settings import OutputConfig
|
|
224
|
+
from e2am.reports.generate import generate_run_artifacts
|
|
225
|
+
|
|
226
|
+
result = _load_result(run_dir)
|
|
227
|
+
written = generate_run_artifacts(result, run_dir, OutputConfig(save_pdf=pdf))
|
|
228
|
+
for path in written:
|
|
229
|
+
console.print(f" [green]wrote[/green] {path}")
|
|
230
|
+
console.print(f"[green]Regenerated {len(written)} artifact(s) for {result.run_name}.[/green]")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@app.command()
|
|
234
|
+
def optimize(
|
|
235
|
+
run_dir: Path = typer.Argument(..., help="A run directory containing metrics.json."),
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Suggest efficiency optimizations based on a run's measurements."""
|
|
238
|
+
from e2am.optimize import analyze
|
|
239
|
+
from e2am.trainer.result import TrainingResult
|
|
240
|
+
|
|
241
|
+
result = _load_result(run_dir)
|
|
242
|
+
if not isinstance(result, TrainingResult):
|
|
243
|
+
_fail(
|
|
244
|
+
"optimize needs a training run (metrics.json from e2am.Trainer); "
|
|
245
|
+
"this directory holds a monitoring-only run."
|
|
246
|
+
)
|
|
247
|
+
return
|
|
248
|
+
suggestions = analyze(result)
|
|
249
|
+
if not suggestions:
|
|
250
|
+
console.print(
|
|
251
|
+
"[green]No optimization opportunities detected[/green] — this run "
|
|
252
|
+
"already uses the measured hardware efficiently."
|
|
253
|
+
)
|
|
254
|
+
return
|
|
255
|
+
table = Table(
|
|
256
|
+
title=f"Optimization suggestions · {result.project} / {result.run_name}",
|
|
257
|
+
title_style="bold green",
|
|
258
|
+
show_lines=True,
|
|
259
|
+
)
|
|
260
|
+
table.add_column("Priority", style="bold", width=8)
|
|
261
|
+
table.add_column("Suggestion")
|
|
262
|
+
table.add_column("Estimated savings", justify="right")
|
|
263
|
+
colors = {"high": "red", "medium": "yellow", "low": "cyan"}
|
|
264
|
+
for s in suggestions:
|
|
265
|
+
table.add_row(
|
|
266
|
+
f"[{colors.get(s.priority, 'white')}]{s.priority}[/]",
|
|
267
|
+
f"[bold]{s.title}[/bold]\n{s.rationale}\n[dim]{s.action}[/dim]",
|
|
268
|
+
s.savings_label(),
|
|
269
|
+
)
|
|
270
|
+
console.print(table)
|
|
271
|
+
quantified = sum(s.estimated_savings_wh or 0 for s in suggestions)
|
|
272
|
+
if quantified > 0:
|
|
273
|
+
console.print(
|
|
274
|
+
f"[bold]Quantified savings from this run's own data: " f"~{quantified:.4g} Wh[/bold]"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@app.command()
|
|
279
|
+
def compare(
|
|
280
|
+
run_dirs: list[Path] = typer.Argument(..., help="Two or more run directories."),
|
|
281
|
+
) -> None:
|
|
282
|
+
"""Compare runs side by side on their headline metrics."""
|
|
283
|
+
if len(run_dirs) < 2:
|
|
284
|
+
_fail("compare needs at least two run directories.")
|
|
285
|
+
flats = []
|
|
286
|
+
for run_dir in run_dirs:
|
|
287
|
+
result = _load_result(run_dir)
|
|
288
|
+
flats.append(result.to_flat_dict())
|
|
289
|
+
|
|
290
|
+
keys: list[str] = []
|
|
291
|
+
for flat in flats:
|
|
292
|
+
for key in flat:
|
|
293
|
+
if key not in keys:
|
|
294
|
+
keys.append(key)
|
|
295
|
+
|
|
296
|
+
table = Table(title="Run comparison", title_style="bold green")
|
|
297
|
+
table.add_column("Metric", style="bold")
|
|
298
|
+
for flat in flats:
|
|
299
|
+
table.add_column(str(flat.get("run_name", "?")), justify="right")
|
|
300
|
+
for key in keys:
|
|
301
|
+
if key == "run_name":
|
|
302
|
+
continue
|
|
303
|
+
values = []
|
|
304
|
+
for flat in flats:
|
|
305
|
+
value = flat.get(key)
|
|
306
|
+
values.append("—" if value in (None, "") else str(value))
|
|
307
|
+
table.add_row(key, *values)
|
|
308
|
+
console.print(table)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@app.command()
|
|
312
|
+
def dashboard(
|
|
313
|
+
results_dir: Path = typer.Argument(Path("results"), help="Results root directory."),
|
|
314
|
+
open_browser: bool = typer.Option(
|
|
315
|
+
True, "--open/--no-open", help="Open the dashboard in the default browser."
|
|
316
|
+
),
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Build a local HTML dashboard of all runs."""
|
|
319
|
+
from e2am.reports.dashboard import generate_dashboard
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
path = generate_dashboard(results_dir)
|
|
323
|
+
except E2AMError as exc:
|
|
324
|
+
_fail(str(exc))
|
|
325
|
+
return
|
|
326
|
+
console.print(f"[green]Dashboard written:[/green] {path}")
|
|
327
|
+
if open_browser:
|
|
328
|
+
import webbrowser
|
|
329
|
+
|
|
330
|
+
webbrowser.open(path.resolve().as_uri())
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
if __name__ == "__main__":
|
|
334
|
+
app()
|
e2am/config/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Typed, YAML-loadable configuration for E2AM experiments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from e2am.config.settings import (
|
|
6
|
+
CarbonConfig,
|
|
7
|
+
ExperimentConfig,
|
|
8
|
+
MonitorConfig,
|
|
9
|
+
OutputConfig,
|
|
10
|
+
TrainerConfig,
|
|
11
|
+
load_config,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CarbonConfig",
|
|
16
|
+
"ExperimentConfig",
|
|
17
|
+
"MonitorConfig",
|
|
18
|
+
"OutputConfig",
|
|
19
|
+
"TrainerConfig",
|
|
20
|
+
"load_config",
|
|
21
|
+
]
|
e2am/config/settings.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Experiment configuration models.
|
|
2
|
+
|
|
3
|
+
Every knob in E2AM lives in a pydantic model here, giving three things at
|
|
4
|
+
once: validation with helpful errors, YAML round-tripping for reproducible
|
|
5
|
+
runs (``config.yaml`` is written into every results directory), and a single
|
|
6
|
+
documented source of truth for defaults.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
from pydantic import BaseModel, Field, field_validator
|
|
16
|
+
|
|
17
|
+
from e2am.exceptions import ConfigError
|
|
18
|
+
|
|
19
|
+
#: Global average grid carbon intensity in gCO2eq/kWh (IEA 2023 estimate).
|
|
20
|
+
WORLD_AVG_CARBON_INTENSITY = 475.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CarbonConfig(BaseModel):
|
|
24
|
+
"""How carbon emissions are computed from energy."""
|
|
25
|
+
|
|
26
|
+
country_iso_code: str | None = Field(
|
|
27
|
+
default=None,
|
|
28
|
+
description="ISO 3166 country code (e.g. 'IND', 'USA') for region-aware intensity.",
|
|
29
|
+
)
|
|
30
|
+
carbon_intensity_g_per_kwh: float = Field(
|
|
31
|
+
default=WORLD_AVG_CARBON_INTENSITY,
|
|
32
|
+
gt=0,
|
|
33
|
+
description="Grid carbon intensity in gCO2eq/kWh. Overridden by "
|
|
34
|
+
"region lookup when a country code is set and lookup data is available.",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MonitorConfig(BaseModel):
|
|
39
|
+
"""Background monitoring behavior."""
|
|
40
|
+
|
|
41
|
+
sampling_interval_s: float = Field(
|
|
42
|
+
default=1.0,
|
|
43
|
+
ge=0.05,
|
|
44
|
+
le=60.0,
|
|
45
|
+
description="Seconds between hardware samples.",
|
|
46
|
+
)
|
|
47
|
+
gpu_indices: list[int] | None = Field(
|
|
48
|
+
default=None,
|
|
49
|
+
description="GPUs to monitor (None = all detected GPUs).",
|
|
50
|
+
)
|
|
51
|
+
cpu_tdp_w: float | None = Field(
|
|
52
|
+
default=None,
|
|
53
|
+
gt=0,
|
|
54
|
+
description="CPU TDP override in watts for energy estimation.",
|
|
55
|
+
)
|
|
56
|
+
carbon: CarbonConfig = Field(default_factory=CarbonConfig)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class OutputConfig(BaseModel):
|
|
60
|
+
"""What gets written to disk after a run."""
|
|
61
|
+
|
|
62
|
+
dir: Path = Field(default=Path("results"), description="Root results directory.")
|
|
63
|
+
save_plots: bool = True
|
|
64
|
+
save_html: bool = True
|
|
65
|
+
save_pdf: bool = False
|
|
66
|
+
save_json: bool = True
|
|
67
|
+
save_csv: bool = True
|
|
68
|
+
|
|
69
|
+
@field_validator("dir", mode="before")
|
|
70
|
+
@classmethod
|
|
71
|
+
def _coerce_path(cls, value: str | Path) -> Path:
|
|
72
|
+
return Path(value)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TrainerConfig(BaseModel):
|
|
76
|
+
"""Training loop behavior for :class:`e2am.Trainer`."""
|
|
77
|
+
|
|
78
|
+
epochs: int = Field(default=10, ge=1)
|
|
79
|
+
device: str | None = Field(
|
|
80
|
+
default=None,
|
|
81
|
+
description="Target device ('cuda', 'cuda:1', 'cpu'). None = auto-detect.",
|
|
82
|
+
)
|
|
83
|
+
mixed_precision: bool = Field(
|
|
84
|
+
default=False,
|
|
85
|
+
description="Enable automatic mixed precision (torch.autocast + GradScaler).",
|
|
86
|
+
)
|
|
87
|
+
gradient_accumulation_steps: int = Field(default=1, ge=1)
|
|
88
|
+
max_grad_norm: float | None = Field(
|
|
89
|
+
default=None,
|
|
90
|
+
gt=0,
|
|
91
|
+
description="Clip gradients to this norm when set.",
|
|
92
|
+
)
|
|
93
|
+
log_every_n_steps: int = Field(default=50, ge=1)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ExperimentConfig(BaseModel):
|
|
97
|
+
"""Top-level configuration for one experiment run."""
|
|
98
|
+
|
|
99
|
+
project: str = Field(default="e2am", min_length=1)
|
|
100
|
+
run_name: str | None = Field(
|
|
101
|
+
default=None,
|
|
102
|
+
description="Run identifier. None = auto ('<project>-<timestamp>').",
|
|
103
|
+
)
|
|
104
|
+
seed: int | None = None
|
|
105
|
+
tags: list[str] = Field(default_factory=list)
|
|
106
|
+
notes: str = ""
|
|
107
|
+
monitor: MonitorConfig = Field(default_factory=MonitorConfig)
|
|
108
|
+
output: OutputConfig = Field(default_factory=OutputConfig)
|
|
109
|
+
trainer: TrainerConfig = Field(default_factory=TrainerConfig)
|
|
110
|
+
|
|
111
|
+
def resolved_run_name(self) -> str:
|
|
112
|
+
"""Return the explicit run name or generate a timestamped one."""
|
|
113
|
+
if self.run_name:
|
|
114
|
+
return self.run_name
|
|
115
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
116
|
+
return f"{self.project}-{stamp}"
|
|
117
|
+
|
|
118
|
+
def run_dir(self) -> Path:
|
|
119
|
+
"""Directory where this run's artifacts are written."""
|
|
120
|
+
return self.output.dir / self.resolved_run_name()
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
# YAML round-tripping
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def from_yaml(cls, path: str | Path) -> ExperimentConfig:
|
|
128
|
+
"""Load a configuration from a YAML file.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
path: Path to a YAML file produced by :meth:`to_yaml` or written
|
|
132
|
+
by hand.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
ConfigError: If the file is missing, unparsable, or invalid.
|
|
136
|
+
"""
|
|
137
|
+
path = Path(path)
|
|
138
|
+
if not path.exists():
|
|
139
|
+
raise ConfigError(f"Config file not found: {path}")
|
|
140
|
+
try:
|
|
141
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
142
|
+
except yaml.YAMLError as exc:
|
|
143
|
+
raise ConfigError(f"Invalid YAML in {path}: {exc}") from exc
|
|
144
|
+
try:
|
|
145
|
+
return cls.model_validate(data)
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
raise ConfigError(f"Invalid configuration in {path}: {exc}") from exc
|
|
148
|
+
|
|
149
|
+
def to_yaml(self, path: str | Path) -> Path:
|
|
150
|
+
"""Write this configuration to a YAML file and return the path."""
|
|
151
|
+
path = Path(path)
|
|
152
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
data = self.model_dump(mode="json")
|
|
154
|
+
path.write_text(
|
|
155
|
+
yaml.safe_dump(data, sort_keys=False, default_flow_style=False),
|
|
156
|
+
encoding="utf-8",
|
|
157
|
+
)
|
|
158
|
+
return path
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def load_config(path: str | Path) -> ExperimentConfig:
|
|
162
|
+
"""Convenience wrapper for :meth:`ExperimentConfig.from_yaml`."""
|
|
163
|
+
return ExperimentConfig.from_yaml(path)
|
e2am/exceptions.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Exception hierarchy for E2AM.
|
|
2
|
+
|
|
3
|
+
All E2AM exceptions inherit from :class:`E2AMError`, so callers can catch a
|
|
4
|
+
single base class. Modules raise the most specific subclass available.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ConfigError",
|
|
11
|
+
"E2AMError",
|
|
12
|
+
"HardwareError",
|
|
13
|
+
"MonitorError",
|
|
14
|
+
"ProfilerError",
|
|
15
|
+
"ReportError",
|
|
16
|
+
"TrainerError",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class E2AMError(Exception):
|
|
21
|
+
"""Base class for all E2AM errors."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConfigError(E2AMError):
|
|
25
|
+
"""Raised when configuration is invalid or cannot be loaded."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HardwareError(E2AMError):
|
|
29
|
+
"""Raised when hardware detection or access fails unexpectedly."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MonitorError(E2AMError):
|
|
33
|
+
"""Raised when the monitoring session cannot start or record data."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ProfilerError(E2AMError):
|
|
37
|
+
"""Raised when model profiling (FLOPs, latency, memory) fails."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TrainerError(E2AMError):
|
|
41
|
+
"""Raised when the training loop is misconfigured or fails."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ReportError(E2AMError):
|
|
45
|
+
"""Raised when report generation fails."""
|