constraintml 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.
- constraintml/__init__.py +5 -0
- constraintml/cloud/__init__.py +29 -0
- constraintml/constraints/__init__.py +3 -0
- constraintml/constraints/parsing.py +98 -0
- constraintml/constraints/spec.py +81 -0
- constraintml/constraints/units.py +54 -0
- constraintml/optimization/__init__.py +17 -0
- constraintml/optimization/backends/__init__.py +4 -0
- constraintml/optimization/backends/base.py +29 -0
- constraintml/optimization/backends/pytorch_backend.py +116 -0
- constraintml/optimization/carbon.py +20 -0
- constraintml/optimization/engine.py +76 -0
- constraintml/optimization/planner.py +151 -0
- constraintml/optimization/state.py +49 -0
- constraintml/optimization/strategies.py +34 -0
- constraintml/optimization/telemetry/__init__.py +12 -0
- constraintml/optimization/telemetry/base.py +41 -0
- constraintml/optimization/telemetry/factory.py +34 -0
- constraintml/optimization/telemetry/nvml.py +51 -0
- constraintml/optimization/telemetry/simulated.py +38 -0
- constraintml/py.typed +0 -0
- constraintml/trainers/__init__.py +5 -0
- constraintml/trainers/base.py +211 -0
- constraintml/trainers/green_trainer.py +37 -0
- constraintml/trainers/smart_trainer.py +41 -0
- constraintml-0.1.0.dist-info/METADATA +261 -0
- constraintml-0.1.0.dist-info/RECORD +30 -0
- constraintml-0.1.0.dist-info/WHEEL +5 -0
- constraintml-0.1.0.dist-info/licenses/LICENSE +21 -0
- constraintml-0.1.0.dist-info/top_level.txt +1 -0
constraintml/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Extension point for optional, provider-specific cloud adapters (AWS/Azure/GCP).
|
|
2
|
+
|
|
3
|
+
For v2+ - not implemented in v1 - ConstraintML is local-first and must work fully without
|
|
4
|
+
any cloud adapter. This module only documents the shape a future adapter would take
|
|
5
|
+
so that `CarbonModel.source` and cost accounting have a defined place to plug into.
|
|
6
|
+
No network or authentication code lives here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@runtime_checkable
|
|
15
|
+
class CloudAdapter(Protocol):
|
|
16
|
+
"""Shape a future cloud adapter would implement. Not used by v1 -- the
|
|
17
|
+
RuntimeOptimizationEngine never depends on this Protocol being satisfied.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def get_carbon_intensity(self, region: str) -> float:
|
|
21
|
+
"""Return regional grid carbon intensity in kg CO2e/kWh."""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
def get_energy_price(self, region: str) -> float:
|
|
25
|
+
"""Return regional electricity price in USD/kWh."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = ["CloudAdapter"]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Parsing helpers that turn user-facing constraint strings/numbers into typed values.
|
|
2
|
+
|
|
3
|
+
Every parser accepts either a bare number (assumed to already be in the target unit)
|
|
4
|
+
or a string like "8h", "<50kWh", "25 kg CO2e". A leading comparator ("<", "<=") is
|
|
5
|
+
informational only -- v1 treats every parsed budget as an upper bound regardless of
|
|
6
|
+
the comparator given. A leading ">"/">=" logs a warning since it is nonsensical for
|
|
7
|
+
a resource budget, but the numeric value is still used as an upper bound.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
14
|
+
from numbers import Number
|
|
15
|
+
|
|
16
|
+
from .units import CARBON_UNITS_TO_KG, DURATION_UNITS_TO_SECONDS, ENERGY_UNITS_TO_KWH, normalize_unit
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_VALUE_PATTERN = re.compile(r"^\s*([<>]=?)?\s*([\d.]+)\s*(.*?)\s*$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _split_value_unit(value: str | Number) -> tuple[float, str]:
|
|
24
|
+
if isinstance(value, Number):
|
|
25
|
+
return float(value), ""
|
|
26
|
+
|
|
27
|
+
match = _VALUE_PATTERN.match(str(value))
|
|
28
|
+
if not match:
|
|
29
|
+
raise ValueError(f"Could not parse constraint value: {value!r}")
|
|
30
|
+
|
|
31
|
+
comparator, number, unit = match.groups()
|
|
32
|
+
if comparator in (">", ">="):
|
|
33
|
+
logger.warning(
|
|
34
|
+
"Constraint value %r uses comparator %r, which is nonsensical for an upper-bound "
|
|
35
|
+
"budget; the numeric value will still be treated as an upper bound.",
|
|
36
|
+
value,
|
|
37
|
+
comparator,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
number = float(number)
|
|
41
|
+
if number < 0:
|
|
42
|
+
raise ValueError(f"Constraint value must be non-negative, got: {value!r}")
|
|
43
|
+
|
|
44
|
+
return number, unit
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_duration(value: str | Number) -> float:
|
|
48
|
+
"""Parse a duration like "8h", "30m", "1d", or a bare number of seconds -> seconds."""
|
|
49
|
+
number, unit = _split_value_unit(value)
|
|
50
|
+
unit = normalize_unit(unit)
|
|
51
|
+
if unit == "":
|
|
52
|
+
return number
|
|
53
|
+
if unit not in DURATION_UNITS_TO_SECONDS:
|
|
54
|
+
raise ValueError(f"Unknown duration unit {unit!r} in {value!r}")
|
|
55
|
+
return number * DURATION_UNITS_TO_SECONDS[unit]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_energy(value: str | Number) -> float:
|
|
59
|
+
"""Parse an energy budget like "100kWh", "<50kWh", or a bare number -> kWh."""
|
|
60
|
+
number, unit = _split_value_unit(value)
|
|
61
|
+
unit = normalize_unit(unit)
|
|
62
|
+
if unit not in ENERGY_UNITS_TO_KWH:
|
|
63
|
+
raise ValueError(f"Unknown energy unit {unit!r} in {value!r}")
|
|
64
|
+
return number * ENERGY_UNITS_TO_KWH[unit]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_carbon(value: str | Number) -> float:
|
|
68
|
+
"""Parse a carbon budget like "25 kg CO2e", "8kgCO2e", or a bare number -> kg CO2e."""
|
|
69
|
+
number, unit = _split_value_unit(value)
|
|
70
|
+
unit = normalize_unit(unit)
|
|
71
|
+
if unit not in CARBON_UNITS_TO_KG:
|
|
72
|
+
raise ValueError(f"Unknown carbon unit {unit!r} in {value!r}")
|
|
73
|
+
return number * CARBON_UNITS_TO_KG[unit]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_cost(value: str | Number) -> float:
|
|
77
|
+
"""Parse a cost budget like "$50", "50 USD", or a bare number -> USD."""
|
|
78
|
+
if isinstance(value, Number):
|
|
79
|
+
number = float(value)
|
|
80
|
+
else:
|
|
81
|
+
cleaned = str(value).strip().lstrip("$").replace(",", "")
|
|
82
|
+
cleaned = re.sub(r"(?i)\s*usd\s*$", "", cleaned)
|
|
83
|
+
number, _unit = _split_value_unit(cleaned)
|
|
84
|
+
if number < 0:
|
|
85
|
+
raise ValueError(f"Cost budget must be non-negative, got: {value!r}")
|
|
86
|
+
return number
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_percentage(value: str | Number) -> float:
|
|
90
|
+
"""Parse a percentage like "0.5%" -> 0.005. A bare number < 1 is assumed to already be
|
|
91
|
+
a fraction; a bare number >= 1 is assumed to be a percentage (e.g. 5 -> 0.05)."""
|
|
92
|
+
number, unit = _split_value_unit(value)
|
|
93
|
+
unit = normalize_unit(unit)
|
|
94
|
+
if unit == "%":
|
|
95
|
+
return number / 100
|
|
96
|
+
if unit != "":
|
|
97
|
+
raise ValueError(f"Unknown percentage unit {unit!r} in {value!r}")
|
|
98
|
+
return number if number < 1 else number / 100
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""ConstraintSpec: the normalized, internal representation of user-supplied budgets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from numbers import Number
|
|
7
|
+
|
|
8
|
+
from .parsing import parse_carbon, parse_cost, parse_duration, parse_energy, parse_percentage
|
|
9
|
+
|
|
10
|
+
OPTIMIZE_FOR_CHOICES = ("accuracy", "energy", "carbon", "cost", "time")
|
|
11
|
+
|
|
12
|
+
_DICT_KEY_ALIASES = {
|
|
13
|
+
"energy": "energy_budget",
|
|
14
|
+
"energy_budget": "energy_budget",
|
|
15
|
+
"carbon": "carbon_budget",
|
|
16
|
+
"carbon_budget": "carbon_budget",
|
|
17
|
+
"deadline": "deadline",
|
|
18
|
+
"max_accuracy_loss": "max_accuracy_loss",
|
|
19
|
+
"accuracy_loss": "max_accuracy_loss",
|
|
20
|
+
"cost": "cost_budget",
|
|
21
|
+
"cost_budget": "cost_budget",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ConstraintSpec:
|
|
27
|
+
energy_budget_kwh: float | None = None
|
|
28
|
+
carbon_budget_kgco2e: float | None = None
|
|
29
|
+
deadline_seconds: float | None = None
|
|
30
|
+
max_accuracy_loss: float | None = None
|
|
31
|
+
cost_budget_usd: float | None = None
|
|
32
|
+
optimize_for: str = "accuracy"
|
|
33
|
+
|
|
34
|
+
def __post_init__(self):
|
|
35
|
+
if self.optimize_for not in OPTIMIZE_FOR_CHOICES:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
f"optimize_for must be one of {OPTIMIZE_FOR_CHOICES}, got {self.optimize_for!r}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_kwargs(
|
|
42
|
+
cls,
|
|
43
|
+
*,
|
|
44
|
+
energy_budget: str | Number | None = None,
|
|
45
|
+
carbon_budget: str | Number | None = None,
|
|
46
|
+
deadline: str | Number | None = None,
|
|
47
|
+
max_accuracy_loss: str | Number | None = None,
|
|
48
|
+
cost_budget: str | Number | None = None,
|
|
49
|
+
optimize_for: str = "accuracy",
|
|
50
|
+
) -> "ConstraintSpec":
|
|
51
|
+
return cls(
|
|
52
|
+
energy_budget_kwh=parse_energy(energy_budget) if energy_budget is not None else None,
|
|
53
|
+
carbon_budget_kgco2e=parse_carbon(carbon_budget) if carbon_budget is not None else None,
|
|
54
|
+
deadline_seconds=parse_duration(deadline) if deadline is not None else None,
|
|
55
|
+
max_accuracy_loss=parse_percentage(max_accuracy_loss) if max_accuracy_loss is not None else None,
|
|
56
|
+
cost_budget_usd=parse_cost(cost_budget) if cost_budget is not None else None,
|
|
57
|
+
optimize_for=optimize_for,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_dict(cls, constraints: dict, optimize_for: str = "accuracy") -> "ConstraintSpec":
|
|
62
|
+
kwargs: dict = {}
|
|
63
|
+
for key, value in constraints.items():
|
|
64
|
+
try:
|
|
65
|
+
field = _DICT_KEY_ALIASES[key]
|
|
66
|
+
except KeyError:
|
|
67
|
+
raise ValueError(f"Unknown constraint key: {key!r}") from None
|
|
68
|
+
kwargs[field] = value
|
|
69
|
+
return cls.from_kwargs(optimize_for=optimize_for, **kwargs)
|
|
70
|
+
|
|
71
|
+
def merge(self, other: "ConstraintSpec") -> "ConstraintSpec":
|
|
72
|
+
"""Return a spec with `other`'s non-None fields overriding this spec's fields.
|
|
73
|
+
|
|
74
|
+
Used to let named kwargs (e.g. GreenTrainer(energy_budget=...)) win over a
|
|
75
|
+
`constraints={...}` dict passed to the same constructor.
|
|
76
|
+
"""
|
|
77
|
+
merged = {}
|
|
78
|
+
for field_name in self.__dataclass_fields__:
|
|
79
|
+
other_value = getattr(other, field_name)
|
|
80
|
+
merged[field_name] = other_value if other_value is not None else getattr(self, field_name)
|
|
81
|
+
return ConstraintSpec(**merged)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Unit lookup tables used by constraints.parsing."""
|
|
2
|
+
|
|
3
|
+
DURATION_UNITS_TO_SECONDS = {
|
|
4
|
+
"s": 1,
|
|
5
|
+
"sec": 1,
|
|
6
|
+
"secs": 1,
|
|
7
|
+
"second": 1,
|
|
8
|
+
"seconds": 1,
|
|
9
|
+
"m": 60,
|
|
10
|
+
"min": 60,
|
|
11
|
+
"mins": 60,
|
|
12
|
+
"minute": 60,
|
|
13
|
+
"minutes": 60,
|
|
14
|
+
"h": 3600,
|
|
15
|
+
"hr": 3600,
|
|
16
|
+
"hrs": 3600,
|
|
17
|
+
"hour": 3600,
|
|
18
|
+
"hours": 3600,
|
|
19
|
+
"d": 86400,
|
|
20
|
+
"day": 86400,
|
|
21
|
+
"days": 86400,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# All keys normalized (lowercase, spaces/dashes/underscores stripped) to kWh.
|
|
25
|
+
ENERGY_UNITS_TO_KWH = {
|
|
26
|
+
"kwh": 1.0,
|
|
27
|
+
"wh": 1e-3,
|
|
28
|
+
"mwh": 1e3,
|
|
29
|
+
"j": 1 / 3_600_000,
|
|
30
|
+
"joule": 1 / 3_600_000,
|
|
31
|
+
"joules": 1 / 3_600_000,
|
|
32
|
+
"kj": 1e3 / 3_600_000,
|
|
33
|
+
"mj": 1e6 / 3_600_000,
|
|
34
|
+
"": 1.0, # bare number defaults to kWh
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# All keys normalized (lowercase, spaces/dashes/underscores stripped) to kg CO2e.
|
|
38
|
+
CARBON_UNITS_TO_KG = {
|
|
39
|
+
"kgco2e": 1.0,
|
|
40
|
+
"kgco2": 1.0,
|
|
41
|
+
"kg": 1.0,
|
|
42
|
+
"gco2e": 1e-3,
|
|
43
|
+
"gco2": 1e-3,
|
|
44
|
+
"g": 1e-3,
|
|
45
|
+
"tco2e": 1e3,
|
|
46
|
+
"tco2": 1e3,
|
|
47
|
+
"t": 1e3,
|
|
48
|
+
"": 1.0, # bare number defaults to kg CO2e
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def normalize_unit(unit: str) -> str:
|
|
53
|
+
"""Lowercase and strip spaces/dashes/underscores so 'kg CO2e' == 'kgCO2e' == 'kg-co2e'."""
|
|
54
|
+
return unit.strip().lower().replace(" ", "").replace("-", "").replace("_", "")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .carbon import CarbonModel, DEFAULT_GRID_CARBON_INTENSITY_KG_PER_KWH
|
|
2
|
+
from .engine import RuntimeOptimizationEngine
|
|
3
|
+
from .planner import ConstraintPlanner
|
|
4
|
+
from .state import EpochSnapshot, RunState
|
|
5
|
+
from .strategies import ExecutionStrategy, PlannerDecision, StrategyAction
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"CarbonModel",
|
|
9
|
+
"DEFAULT_GRID_CARBON_INTENSITY_KG_PER_KWH",
|
|
10
|
+
"RuntimeOptimizationEngine",
|
|
11
|
+
"ConstraintPlanner",
|
|
12
|
+
"RunState",
|
|
13
|
+
"EpochSnapshot",
|
|
14
|
+
"ExecutionStrategy",
|
|
15
|
+
"PlannerDecision",
|
|
16
|
+
"StrategyAction",
|
|
17
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Framework-agnostic execution backend interface.
|
|
2
|
+
|
|
3
|
+
v1 ships only `PyTorchBackend`. JAX/TensorFlow backends would implement this same
|
|
4
|
+
protocol; none exist yet.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..strategies import ExecutionStrategy
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ExecutionBackend(ABC):
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def training_step(self, batch: Any, loss_fn: Any) -> float:
|
|
18
|
+
"""Run one training step (forward + backward + optimizer update as appropriate
|
|
19
|
+
given accumulation state) and return the scalar loss value."""
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def apply_strategy(self, strategy: ExecutionStrategy) -> None:
|
|
23
|
+
"""Reconfigure execution (precision, batch size, grad accumulation,
|
|
24
|
+
checkpointing) to match the given strategy."""
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def evaluate(self, val_loader: Any) -> float | None:
|
|
28
|
+
"""Run validation and return an accuracy-like metric (higher is better),
|
|
29
|
+
or None if it cannot be computed."""
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""PyTorch execution backend: the only real ExecutionBackend implementation for v1."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
from torch.utils.data import DataLoader, RandomSampler
|
|
9
|
+
|
|
10
|
+
from ..strategies import ExecutionStrategy
|
|
11
|
+
from .base import ExecutionBackend
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PyTorchBackend(ExecutionBackend):
|
|
17
|
+
def __init__(self, model, optimizer, train_loader, loss_fn, device=None):
|
|
18
|
+
self.model = model
|
|
19
|
+
self.optimizer = optimizer
|
|
20
|
+
self.train_loader = train_loader
|
|
21
|
+
self.loss_fn = loss_fn
|
|
22
|
+
self.device = device or torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
|
23
|
+
self.model.to(self.device)
|
|
24
|
+
|
|
25
|
+
initial_batch_size = getattr(train_loader, "batch_size", None) or 0
|
|
26
|
+
self.strategy = ExecutionStrategy(batch_size=initial_batch_size)
|
|
27
|
+
self._initial_batch_size = initial_batch_size
|
|
28
|
+
self._micro_step = 0
|
|
29
|
+
self._checkpoint_warned = False
|
|
30
|
+
|
|
31
|
+
self.optimizer.zero_grad()
|
|
32
|
+
|
|
33
|
+
def apply_strategy(self, strategy: ExecutionStrategy) -> None:
|
|
34
|
+
if strategy.batch_size and strategy.batch_size != self.strategy.batch_size:
|
|
35
|
+
self._resize_batch(strategy.batch_size)
|
|
36
|
+
self.strategy = strategy
|
|
37
|
+
|
|
38
|
+
def _resize_batch(self, batch_size: int) -> None:
|
|
39
|
+
dataset = getattr(self.train_loader, "dataset", None)
|
|
40
|
+
if dataset is None:
|
|
41
|
+
logger.warning(
|
|
42
|
+
"Cannot resize batch size to %d: train_loader has no `.dataset` "
|
|
43
|
+
"(only map-style DataLoaders support runtime batch-size changes in v1).",
|
|
44
|
+
batch_size,
|
|
45
|
+
)
|
|
46
|
+
return
|
|
47
|
+
shuffled = isinstance(getattr(self.train_loader, "sampler", None), RandomSampler)
|
|
48
|
+
self.train_loader = DataLoader(
|
|
49
|
+
dataset,
|
|
50
|
+
batch_size=batch_size,
|
|
51
|
+
shuffle=shuffled,
|
|
52
|
+
num_workers=getattr(self.train_loader, "num_workers", 0),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def training_step(self, batch, loss_fn=None) -> float:
|
|
56
|
+
loss_fn = loss_fn or self.loss_fn
|
|
57
|
+
inputs, targets = batch
|
|
58
|
+
inputs = inputs.to(self.device)
|
|
59
|
+
targets = targets.to(self.device)
|
|
60
|
+
|
|
61
|
+
autocast_enabled = self.strategy.precision == "fp16"
|
|
62
|
+
# CPU has no native fp16 compute kernels -- torch.autocast(dtype=float16) on CPU
|
|
63
|
+
# falls back to emulation and measured ~44x SLOWER than fp32 for a mid-sized MLP
|
|
64
|
+
# on this machine, actively defeating the time/energy budget instead of helping it.
|
|
65
|
+
# bfloat16 is the dtype CPU autocast is actually designed around; fp16 remains
|
|
66
|
+
# correct for CUDA, where it's backed by real tensor-core throughput.
|
|
67
|
+
autocast_dtype = torch.bfloat16 if self.device.type == "cpu" else torch.float16
|
|
68
|
+
with torch.autocast(device_type=self.device.type, enabled=autocast_enabled, dtype=autocast_dtype):
|
|
69
|
+
outputs = self._forward(inputs)
|
|
70
|
+
loss = loss_fn(outputs, targets)
|
|
71
|
+
|
|
72
|
+
(loss / self.strategy.grad_accumulation_steps).backward()
|
|
73
|
+
self._micro_step += 1
|
|
74
|
+
|
|
75
|
+
if self._micro_step % self.strategy.grad_accumulation_steps == 0:
|
|
76
|
+
self.optimizer.step()
|
|
77
|
+
self.optimizer.zero_grad()
|
|
78
|
+
|
|
79
|
+
return loss.item()
|
|
80
|
+
|
|
81
|
+
def _forward(self, inputs):
|
|
82
|
+
if self.strategy.activation_checkpointing:
|
|
83
|
+
if isinstance(self.model, torch.nn.Sequential) and len(self.model) > 1:
|
|
84
|
+
return torch.utils.checkpoint.checkpoint_sequential(
|
|
85
|
+
self.model, len(self.model), inputs, use_reentrant=False
|
|
86
|
+
)
|
|
87
|
+
if not self._checkpoint_warned:
|
|
88
|
+
logger.warning(
|
|
89
|
+
"Activation checkpointing requested but the model is not an "
|
|
90
|
+
"nn.Sequential, so its forward pass cannot be automatically "
|
|
91
|
+
"wrapped in v1. Continuing without checkpointing."
|
|
92
|
+
)
|
|
93
|
+
self._checkpoint_warned = True
|
|
94
|
+
return self.model(inputs)
|
|
95
|
+
|
|
96
|
+
def evaluate(self, val_loader) -> float | None:
|
|
97
|
+
if val_loader is None or self.loss_fn is None:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
self.model.eval()
|
|
101
|
+
total_loss = 0.0
|
|
102
|
+
total_batches = 0
|
|
103
|
+
with torch.no_grad():
|
|
104
|
+
for inputs, targets in val_loader:
|
|
105
|
+
inputs = inputs.to(self.device)
|
|
106
|
+
targets = targets.to(self.device)
|
|
107
|
+
outputs = self.model(inputs)
|
|
108
|
+
total_loss += self.loss_fn(outputs, targets).item()
|
|
109
|
+
total_batches += 1
|
|
110
|
+
self.model.train()
|
|
111
|
+
|
|
112
|
+
if total_batches == 0:
|
|
113
|
+
return None
|
|
114
|
+
# "Accuracy-like" metric where higher is better; without a task-specific
|
|
115
|
+
# metric available, negative average validation loss becomes the generic proxy.
|
|
116
|
+
return -(total_loss / total_batches)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Carbon accounting: converts energy consumption into estimated carbon emissions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
# Rough global-average grid carbon intensity. This is a coarse approximation --
|
|
8
|
+
# override with a locally accurate figure via `carbon_intensity_kg_per_kwh=` on any
|
|
9
|
+
# trainer. A future `constraintml.cloud` adapter is the intended extension point for
|
|
10
|
+
# sourcing a regionally accurate figure automatically (see CarbonModel.source).
|
|
11
|
+
DEFAULT_GRID_CARBON_INTENSITY_KG_PER_KWH = 0.4
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class CarbonModel:
|
|
16
|
+
intensity_kg_per_kwh: float = DEFAULT_GRID_CARBON_INTENSITY_KG_PER_KWH
|
|
17
|
+
source: str = "static_default"
|
|
18
|
+
|
|
19
|
+
def to_carbon(self, energy_kwh: float) -> float:
|
|
20
|
+
return energy_kwh * self.intensity_kg_per_kwh
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""RuntimeOptimizationEngine: orchestrates telemetry, carbon accounting, and the
|
|
2
|
+
planner around each training step, applying any resulting strategy to the backend.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from .backends.base import ExecutionBackend
|
|
10
|
+
from .carbon import CarbonModel
|
|
11
|
+
from .planner import ConstraintPlanner
|
|
12
|
+
from .state import RunState
|
|
13
|
+
from .strategies import StrategyAction
|
|
14
|
+
from .telemetry.base import TelemetryCollector, TelemetrySample
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RuntimeOptimizationEngine:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
planner: ConstraintPlanner,
|
|
21
|
+
telemetry: TelemetryCollector,
|
|
22
|
+
backend: ExecutionBackend,
|
|
23
|
+
carbon_model: CarbonModel | None = None,
|
|
24
|
+
cost_per_kwh_usd: float | None = None,
|
|
25
|
+
state: RunState | None = None,
|
|
26
|
+
):
|
|
27
|
+
self.planner = planner
|
|
28
|
+
self.telemetry = telemetry
|
|
29
|
+
self.backend = backend
|
|
30
|
+
self.carbon_model = carbon_model or CarbonModel()
|
|
31
|
+
self.cost_per_kwh_usd = cost_per_kwh_usd
|
|
32
|
+
self.state = state or RunState()
|
|
33
|
+
|
|
34
|
+
self._step_start_time: float | None = None
|
|
35
|
+
self._step_start_sample: TelemetrySample | None = None
|
|
36
|
+
|
|
37
|
+
def begin_step(self) -> None:
|
|
38
|
+
self._step_start_time = time.perf_counter()
|
|
39
|
+
self._step_start_sample = self.telemetry.sample()
|
|
40
|
+
|
|
41
|
+
def end_step(self, loss: float | None = None) -> None:
|
|
42
|
+
if self._step_start_time is None or self._step_start_sample is None:
|
|
43
|
+
raise RuntimeError("end_step() called without a matching begin_step().")
|
|
44
|
+
|
|
45
|
+
end_sample = self.telemetry.sample()
|
|
46
|
+
elapsed = time.perf_counter() - self._step_start_time
|
|
47
|
+
|
|
48
|
+
energy_kwh = self.telemetry.integrate_energy(self._step_start_sample, end_sample, elapsed)
|
|
49
|
+
carbon_kgco2e = self.carbon_model.to_carbon(energy_kwh)
|
|
50
|
+
|
|
51
|
+
self.state.cumulative_energy_kwh += energy_kwh
|
|
52
|
+
self.state.cumulative_carbon_kgco2e += carbon_kgco2e
|
|
53
|
+
if self.cost_per_kwh_usd is not None:
|
|
54
|
+
self.state.cumulative_cost_usd += energy_kwh * self.cost_per_kwh_usd
|
|
55
|
+
self.state.elapsed_seconds += elapsed
|
|
56
|
+
self.state.step_count += 1
|
|
57
|
+
|
|
58
|
+
self._step_start_time = None
|
|
59
|
+
self._step_start_sample = None
|
|
60
|
+
|
|
61
|
+
if self.planner.should_evaluate(self.state):
|
|
62
|
+
decision = self.planner.decide(self.state)
|
|
63
|
+
self._apply_decision(decision)
|
|
64
|
+
|
|
65
|
+
def _apply_decision(self, decision) -> None:
|
|
66
|
+
if decision.action is StrategyAction.EARLY_STOP:
|
|
67
|
+
self.state.stopped_early = True
|
|
68
|
+
self.state.stop_reason = decision.reason
|
|
69
|
+
return
|
|
70
|
+
if decision.action is StrategyAction.NO_CHANGE:
|
|
71
|
+
return
|
|
72
|
+
self.backend.apply_strategy(decision.resulting_strategy)
|
|
73
|
+
self.state.record_strategy(decision.resulting_strategy)
|
|
74
|
+
|
|
75
|
+
def should_stop(self) -> bool:
|
|
76
|
+
return self.state.stopped_early
|