greenpick 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.
- greenpick/__init__.py +7 -0
- greenpick/__main__.py +18 -0
- greenpick/config.py +58 -0
- greenpick/data/__init__.py +28 -0
- greenpick/data/auto_repository.py +47 -0
- greenpick/data/eia_repository.py +156 -0
- greenpick/data/electricitymaps_repository.py +104 -0
- greenpick/data/emission_factors.json +35 -0
- greenpick/data/entsoe_repository.py +401 -0
- greenpick/data/gpu_power.json +26 -0
- greenpick/data/inference_energy.json +50 -0
- greenpick/data/instance_families.json +32 -0
- greenpick/data/mock_repository.py +53 -0
- greenpick/data/network.json +7 -0
- greenpick/data/power.json +7 -0
- greenpick/data/pricing.json +31 -0
- greenpick/data/regions.json +22 -0
- greenpick/data/repository.py +27 -0
- greenpick/data/static_data.py +105 -0
- greenpick/models.py +342 -0
- greenpick/server.py +349 -0
- greenpick/services/__init__.py +5 -0
- greenpick/services/carbon_service.py +28 -0
- greenpick/services/cost_service.py +21 -0
- greenpick/services/forecast_service.py +72 -0
- greenpick/services/inference_service.py +100 -0
- greenpick/services/instance_service.py +132 -0
- greenpick/services/latency_service.py +33 -0
- greenpick/services/locality_service.py +160 -0
- greenpick/services/metric_scaffold_service.py +149 -0
- greenpick/services/recommender.py +203 -0
- greenpick/services/residency_service.py +53 -0
- greenpick/services/rightsizing_service.py +93 -0
- greenpick/services/scheduling_service.py +211 -0
- greenpick/services/serving_service.py +202 -0
- greenpick/services/training_service.py +209 -0
- greenpick-0.1.0.dist-info/METADATA +271 -0
- greenpick-0.1.0.dist-info/RECORD +42 -0
- greenpick-0.1.0.dist-info/WHEEL +5 -0
- greenpick-0.1.0.dist-info/entry_points.txt +2 -0
- greenpick-0.1.0.dist-info/licenses/LICENSE +21 -0
- greenpick-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Serving-pattern advice: should I serve my users from ONE region or SEVERAL?
|
|
2
|
+
|
|
3
|
+
A design-time question for a user-facing platform: given where your users are (rough geographic
|
|
4
|
+
shares are fine), is it better to serve everyone from a single region, or run multiple regions
|
|
5
|
+
closer to each hub? GreenPick scores both on traffic-weighted latency and grid carbon, then
|
|
6
|
+
composes the caveats that make multi-region non-free: cross-region DATA-SYNC egress (keeping shared
|
|
7
|
+
state consistent across regions) and cross-JURISDICTION residency (a hub served from another
|
|
8
|
+
jurisdiction).
|
|
9
|
+
|
|
10
|
+
Firmly in GreenPick's lane: design-time, directional, stateless, reuses carbon + latency + egress +
|
|
11
|
+
residency. It is NOT a runtime meter — it never measures a running app; it advises before you build.
|
|
12
|
+
Input (user geography) is soft and often unknown precisely at design time, so rough % shares are
|
|
13
|
+
accepted and everything is labelled directional.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from ..models import (
|
|
20
|
+
Workload, GeoPoint, Provider, ServingHub, ServingSinglePlan,
|
|
21
|
+
ServingPerHubChoice, ServingPatternAdvice,
|
|
22
|
+
)
|
|
23
|
+
from ..data.repository import CarbonRepository
|
|
24
|
+
from ..data.static_data import iter_regions, region_meta
|
|
25
|
+
from . import carbon_service, latency_service, residency_service
|
|
26
|
+
|
|
27
|
+
# Representative coordinates for common named hubs, so callers can say "eu"/"us"/"apac" without
|
|
28
|
+
# supplying lat/lon. Rough centroids of where users typically are, not of the regions.
|
|
29
|
+
_HUB_COORDS = {
|
|
30
|
+
"us": (39.0, -98.0), "us-east": (38.9, -77.0), "us-west": (37.8, -122.3),
|
|
31
|
+
"eu": (50.1, 8.7), "eu-west": (53.3, -6.3), "eu-north": (59.3, 18.1),
|
|
32
|
+
"uk": (51.5, -0.1), "apac": (1.35, 103.8), "au": (-33.9, 151.2),
|
|
33
|
+
"india": (19.1, 72.9), "japan": (35.7, 139.7), "sa": (-23.5, -46.6),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
LIMITATIONS = (
|
|
37
|
+
"Directional, design-time guidance — NOT a runtime measurement of your app. User geography is "
|
|
38
|
+
"whatever you supply (rough % shares are fine); results are only as good as that estimate. "
|
|
39
|
+
"Multi-region adds operational complexity, data-sync egress, and possible residency constraints "
|
|
40
|
+
"that GreenPick flags but does not fully cost. Carbon/latency are directional (see "
|
|
41
|
+
"explain_methodology_and_limits)."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_hub(h: ServingHub) -> GeoPoint:
|
|
46
|
+
"""A hub may give explicit lat/lon, or rely on a known name -> representative coords."""
|
|
47
|
+
key = h.name.strip().lower()
|
|
48
|
+
if key in _HUB_COORDS and (h.lat == 0 and h.lon == 0):
|
|
49
|
+
lat, lon = _HUB_COORDS[key]
|
|
50
|
+
return GeoPoint(lat=lat, lon=lon)
|
|
51
|
+
return GeoPoint(lat=h.lat, lon=h.lon)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def _carbon(repo: CarbonRepository, provider: str, region: str):
|
|
55
|
+
return await carbon_service.carbon_now(repo, provider, region) # type: ignore[arg-type]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def serving_pattern(
|
|
59
|
+
repo: CarbonRepository,
|
|
60
|
+
hubs: list[ServingHub],
|
|
61
|
+
workload: Workload,
|
|
62
|
+
providers: Optional[list[Provider]] = None,
|
|
63
|
+
) -> ServingPatternAdvice:
|
|
64
|
+
if not hubs:
|
|
65
|
+
return ServingPatternAdvice(hubs=[], rationale="No user hubs supplied — give at least one "
|
|
66
|
+
"(name + rough share) so I can advise on where to serve from.",
|
|
67
|
+
limitations=LIMITATIONS)
|
|
68
|
+
|
|
69
|
+
# Normalise shares so they sum to 1 (callers often give rough buckets like 40/40/20).
|
|
70
|
+
total_share = sum(h.share for h in hubs) or 1.0
|
|
71
|
+
weights = [h.share / total_share for h in hubs]
|
|
72
|
+
points = [resolve_hub(h) for h in hubs]
|
|
73
|
+
|
|
74
|
+
wanted = set(providers) if providers else {"aws", "gcp", "azure"}
|
|
75
|
+
catalog = [(p, r) for p, r, _m in iter_regions() if p in wanted]
|
|
76
|
+
|
|
77
|
+
# Carbon per candidate region (one fetch each). Latency is pure geometry, no fetch.
|
|
78
|
+
carbon_cache: dict[tuple[str, str], object] = {}
|
|
79
|
+
import asyncio
|
|
80
|
+
readings = await asyncio.gather(*(_carbon(repo, p, r) for p, r in catalog))
|
|
81
|
+
for (p, r), reading in zip(catalog, readings):
|
|
82
|
+
carbon_cache[(p, r)] = reading
|
|
83
|
+
|
|
84
|
+
def latency(p: str, r: str, pt: GeoPoint) -> Optional[float]:
|
|
85
|
+
return latency_service.estimate_latency_ms(p, r, pt) # type: ignore[arg-type]
|
|
86
|
+
|
|
87
|
+
# --- Single-region-for-all: pick the region minimising traffic-weighted latency ---
|
|
88
|
+
best_single = None
|
|
89
|
+
for (p, r) in catalog:
|
|
90
|
+
lats = [latency(p, r, pt) for pt in points]
|
|
91
|
+
if any(l is None for l in lats):
|
|
92
|
+
continue
|
|
93
|
+
wlat = sum(w * l for w, l in zip(weights, lats)) # type: ignore[arg-type]
|
|
94
|
+
if best_single is None or wlat < best_single[0]:
|
|
95
|
+
best_single = (wlat, p, r)
|
|
96
|
+
|
|
97
|
+
single_plan = None
|
|
98
|
+
if best_single is not None:
|
|
99
|
+
wlat, p, r = best_single
|
|
100
|
+
reading = carbon_cache[(p, r)]
|
|
101
|
+
single_plan = ServingSinglePlan(
|
|
102
|
+
provider=p, region=r, weighted_latency_ms=round(wlat, 1), # type: ignore[arg-type]
|
|
103
|
+
carbon_g_co2_per_kwh=reading.g_co2_per_kwh, carbon_source=reading.source, # type: ignore[attr-defined]
|
|
104
|
+
carbon_quality=reading.quality, # type: ignore[attr-defined]
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# --- Multi-region: for each hub, the region that best balances latency + carbon near it ---
|
|
108
|
+
per_hub: list[ServingPerHubChoice] = []
|
|
109
|
+
for h, pt, w in zip(hubs, points, weights):
|
|
110
|
+
scored = []
|
|
111
|
+
for (p, r) in catalog:
|
|
112
|
+
l = latency(p, r, pt)
|
|
113
|
+
if l is None:
|
|
114
|
+
continue
|
|
115
|
+
reading = carbon_cache[(p, r)]
|
|
116
|
+
# Rank each hub's region by latency first (serving locality is the point of going
|
|
117
|
+
# multi-region), using carbon as the tiebreaker among nearby options (+/-30ms).
|
|
118
|
+
scored.append((l, reading.g_co2_per_kwh, p, r)) # type: ignore[attr-defined]
|
|
119
|
+
if not scored:
|
|
120
|
+
continue
|
|
121
|
+
scored.sort(key=lambda t: t[0])
|
|
122
|
+
near_min = scored[0][0] + 30.0
|
|
123
|
+
nearby = [s for s in scored if s[0] <= near_min]
|
|
124
|
+
nearby.sort(key=lambda t: t[1]) # greenest among the near-latency options
|
|
125
|
+
l, g, p, r = nearby[0]
|
|
126
|
+
per_hub.append(ServingPerHubChoice(
|
|
127
|
+
hub=h.name, provider=p, region=r, latency_ms=round(l, 1),
|
|
128
|
+
carbon_g_co2_per_kwh=g,
|
|
129
|
+
))
|
|
130
|
+
|
|
131
|
+
multi_wlat = None
|
|
132
|
+
multi_blended = None
|
|
133
|
+
if len(per_hub) == len(hubs):
|
|
134
|
+
multi_wlat = round(sum(w * c.latency_ms for w, c in zip(weights, per_hub)), 1)
|
|
135
|
+
multi_blended = round(sum(w * c.carbon_g_co2_per_kwh for w, c in zip(weights, per_hub)), 1)
|
|
136
|
+
|
|
137
|
+
# --- Caveats that make multi-region non-free ---
|
|
138
|
+
data_sync_note = None
|
|
139
|
+
residency_note = None
|
|
140
|
+
distinct_regions = {(c.provider, c.region) for c in per_hub}
|
|
141
|
+
if len(distinct_regions) > 1:
|
|
142
|
+
data_sync_note = (
|
|
143
|
+
"Multi-region means shared state (DBs, sessions, caches) must sync ACROSS regions — "
|
|
144
|
+
"that cross-region traffic is billed egress and adds its own transfer carbon. Model it "
|
|
145
|
+
"with data_locality_egress_check before committing; it can erase the latency/carbon win "
|
|
146
|
+
"for write-heavy or chatty-shared-state apps.")
|
|
147
|
+
# Residency: does any hub get served from a different jurisdiction than the hub sits in?
|
|
148
|
+
for h, pt, c in zip(hubs, points, per_hub):
|
|
149
|
+
hub_jur = _nearest_jurisdiction(pt)
|
|
150
|
+
serve_jur = residency_service.jurisdiction_of(c.provider, c.region)
|
|
151
|
+
if hub_jur and serve_jur and hub_jur != serve_jur:
|
|
152
|
+
residency_note = (
|
|
153
|
+
f"RESIDENCY: at least one hub ('{h.name}') would be served from a region in a "
|
|
154
|
+
f"different jurisdiction ({serve_jur}) than the users appear to sit in ({hub_jur}). "
|
|
155
|
+
f"If user data is regulated, this may matter. " + residency_service.DISCLAIMER)
|
|
156
|
+
break
|
|
157
|
+
|
|
158
|
+
# --- Decide + explain ---
|
|
159
|
+
recommended, rationale = _decide(single_plan, per_hub, multi_wlat, multi_blended,
|
|
160
|
+
len(distinct_regions))
|
|
161
|
+
|
|
162
|
+
return ServingPatternAdvice(
|
|
163
|
+
hubs=hubs, single_region=single_plan, multi_region=per_hub,
|
|
164
|
+
multi_region_weighted_latency_ms=multi_wlat,
|
|
165
|
+
multi_region_blended_carbon_g_co2_per_kwh=multi_blended,
|
|
166
|
+
recommended_pattern=recommended, data_sync_egress_note=data_sync_note,
|
|
167
|
+
residency_note=residency_note, rationale=rationale, limitations=LIMITATIONS,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _nearest_jurisdiction(pt: GeoPoint) -> Optional[str]:
|
|
172
|
+
"""Infer a hub's jurisdiction from the nearest catalogued region's tag (directional)."""
|
|
173
|
+
best = None
|
|
174
|
+
for p, r, meta in iter_regions():
|
|
175
|
+
if "lat" not in meta or "jurisdiction" not in meta:
|
|
176
|
+
continue
|
|
177
|
+
d = (meta["lat"] - pt.lat) ** 2 + (meta["lon"] - pt.lon) ** 2
|
|
178
|
+
if best is None or d < best[0]:
|
|
179
|
+
best = (d, meta["jurisdiction"])
|
|
180
|
+
return best[1] if best else None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _decide(single, per_hub, multi_wlat, multi_blended, n_distinct):
|
|
184
|
+
"""Recommend single vs multi. Single wins unless multi-region materially cuts weighted latency
|
|
185
|
+
(users are genuinely spread), since multi-region's complexity/egress is a real cost."""
|
|
186
|
+
if single is None or not per_hub or multi_wlat is None:
|
|
187
|
+
if single is not None:
|
|
188
|
+
return "single_region", (f"Serve all users from {single.provider}/{single.region} "
|
|
189
|
+
f"(~{single.weighted_latency_ms:.0f}ms weighted latency).")
|
|
190
|
+
return "single_region", "Not enough data to compare serving patterns."
|
|
191
|
+
|
|
192
|
+
lat_saving = single.weighted_latency_ms - multi_wlat
|
|
193
|
+
if n_distinct > 1 and lat_saving >= 40:
|
|
194
|
+
return "multi_region", (
|
|
195
|
+
f"Your users are spread enough that multi-region helps: serving each hub locally cuts "
|
|
196
|
+
f"weighted latency from ~{single.weighted_latency_ms:.0f}ms (single region) to "
|
|
197
|
+
f"~{multi_wlat:.0f}ms, and blends to ~{multi_blended:.0f} gCO2/kWh. Weigh that against "
|
|
198
|
+
f"the data-sync egress and complexity below before committing.")
|
|
199
|
+
return "single_region", (
|
|
200
|
+
f"A single region ({single.provider}/{single.region}, ~{single.weighted_latency_ms:.0f}ms "
|
|
201
|
+
f"weighted) is the better call: multi-region would only save ~{max(0, lat_saving):.0f}ms, "
|
|
202
|
+
f"which rarely justifies the added data-sync egress and operational complexity.")
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""GPU training carbon: how much CO2 a training run emits, and the greener WHERE + WHEN.
|
|
2
|
+
|
|
3
|
+
Training is the workload where GreenPick's two best levers compound: a run is both PORTABLE
|
|
4
|
+
(pick the region) and DEFERRABLE (pick the hours). Unlike the always-on web service, a run is a
|
|
5
|
+
FIXED batch of work (~N GPU-hours, once), so we can place it in space and time.
|
|
6
|
+
|
|
7
|
+
Energy comes from the directional GPU power table (TDP x utilization + host overhead, x PUE);
|
|
8
|
+
carbon = energy x the region's LIVE grid intensity. Placement ranks regions greener-first; timing
|
|
9
|
+
reuses the carbon-aware scheduling service for the chosen (or greenest) region.
|
|
10
|
+
|
|
11
|
+
Honesty posture: energy is directional (TDP is peak, not a measurement of your run); region GPU
|
|
12
|
+
availability is NOT modelled — we flag it as something to verify, rather than maintain a matrix
|
|
13
|
+
that rots. Spot/on-demand, checkpointing, and multi-node networking are out of scope (orchestration,
|
|
14
|
+
not placement/timing).
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from ..models import (
|
|
21
|
+
Provider, TrainingCarbonAdvice, TrainingRegionOption, HourlyCarbon,
|
|
22
|
+
)
|
|
23
|
+
from ..data.repository import CarbonRepository
|
|
24
|
+
from ..data.static_data import gpu_power, resolve_gpu, power_model, iter_regions
|
|
25
|
+
from . import carbon_service, scheduling_service, residency_service
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_energy_kwh(gpu_key: str, gpu_count: int, gpu_hours: float,
|
|
29
|
+
utilization: float) -> float:
|
|
30
|
+
"""Directional energy for a run. gpu_hours is TOTAL accelerator-hours across all GPUs
|
|
31
|
+
(e.g. 2 GPUs for 20h = 40 GPU-hours). Energy is independent of how it's split, since
|
|
32
|
+
both the GPU draw and host overhead are per-accelerator."""
|
|
33
|
+
gp = gpu_power()
|
|
34
|
+
_key, spec = resolve_gpu(gpu_key) # caller guarantees resolvable
|
|
35
|
+
tdp = spec["tdp_watts"]
|
|
36
|
+
host = gp["host_overhead_watts_per_gpu"]
|
|
37
|
+
pue = power_model()["pue_default"]
|
|
38
|
+
watts_per_gpu = tdp * utilization + host
|
|
39
|
+
# gpu_hours is total accelerator-hours, so multiply watts-per-gpu by total gpu-hours directly.
|
|
40
|
+
return watts_per_gpu / 1000.0 * gpu_hours * pue
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _rate(options: list[TrainingRegionOption]) -> None:
|
|
44
|
+
"""Relative R/Y/G by carbon terciles across the option set (greenest = green)."""
|
|
45
|
+
if not options:
|
|
46
|
+
return
|
|
47
|
+
ordered = sorted(options, key=lambda o: o.est_kg_co2)
|
|
48
|
+
n = len(ordered)
|
|
49
|
+
for i, opt in enumerate(ordered):
|
|
50
|
+
frac = i / n
|
|
51
|
+
opt.rating = "green" if frac < 1/3 else "yellow" if frac < 2/3 else "red"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
LIMITATIONS = (
|
|
55
|
+
"Energy is DIRECTIONAL: published GPU TDP (peak, not your run's measured draw) x a utilization "
|
|
56
|
+
"assumption + host overhead x PUE. Carbon = energy x the region's grid intensity. GPU AVAILABILITY "
|
|
57
|
+
"per region is NOT checked — verify the card you need is offered there. Ignores spot/on-demand, "
|
|
58
|
+
"checkpointing, multi-node networking, and embodied hardware carbon. Training datasets are often "
|
|
59
|
+
"large and may live in another region: shipping them to the greenest region can incur significant "
|
|
60
|
+
"EGRESS cost and transfer carbon — run data_locality_egress_check before moving the job."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def estimate_training_carbon(
|
|
65
|
+
repo: CarbonRepository,
|
|
66
|
+
gpu: str,
|
|
67
|
+
gpu_count: int,
|
|
68
|
+
gpu_hours: float,
|
|
69
|
+
region: Optional[str] = None,
|
|
70
|
+
provider: Optional[Provider] = None,
|
|
71
|
+
utilization: Optional[float] = None,
|
|
72
|
+
top_n_regions: int = 5,
|
|
73
|
+
) -> TrainingCarbonAdvice:
|
|
74
|
+
gp = gpu_power()
|
|
75
|
+
resolved = resolve_gpu(gpu)
|
|
76
|
+
util = utilization if utilization is not None else gp["default_utilization"]
|
|
77
|
+
|
|
78
|
+
if resolved is None:
|
|
79
|
+
known = ", ".join(gp["gpus"].keys())
|
|
80
|
+
return TrainingCarbonAdvice(
|
|
81
|
+
gpu=gpu, gpu_name=gpu, gpu_count=gpu_count, gpu_hours=gpu_hours,
|
|
82
|
+
utilization=util, est_energy_kwh=0.0,
|
|
83
|
+
rationale=f"Unknown GPU '{gpu}'. Known: {known}.",
|
|
84
|
+
limitations=LIMITATIONS,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
gpu_key, spec = resolved
|
|
88
|
+
energy = round(run_energy_kwh(gpu_key, gpu_count, gpu_hours, util), 2)
|
|
89
|
+
|
|
90
|
+
base = TrainingCarbonAdvice(
|
|
91
|
+
gpu=gpu_key, gpu_name=spec["name"], gpu_count=gpu_count, gpu_hours=gpu_hours,
|
|
92
|
+
utilization=util, est_energy_kwh=energy, limitations=LIMITATIONS,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# --- Placement: a specific region, or rank the catalog greener-first ---
|
|
96
|
+
if region is not None:
|
|
97
|
+
prov = provider or _guess_provider(region)
|
|
98
|
+
if prov is None:
|
|
99
|
+
base.rationale = (f"Region '{region}' not found. Pass provider= explicitly, or omit "
|
|
100
|
+
f"region to rank all regions.")
|
|
101
|
+
return base
|
|
102
|
+
reading = await carbon_service.carbon_now(repo, prov, region)
|
|
103
|
+
kg = round(energy * reading.g_co2_per_kwh / 1000.0, 2)
|
|
104
|
+
base.region = region
|
|
105
|
+
base.recommended_region = f"{prov}/{region}"
|
|
106
|
+
base.options = [TrainingRegionOption(
|
|
107
|
+
provider=prov, region=region, grid_intensity_g_co2_per_kwh=reading.g_co2_per_kwh,
|
|
108
|
+
carbon_source=reading.source, carbon_quality=reading.quality, est_kg_co2=kg,
|
|
109
|
+
rating="green",
|
|
110
|
+
)]
|
|
111
|
+
chosen_prov, chosen_region = prov, region
|
|
112
|
+
place_txt = (f"This run is ~{energy:.0f} kWh. In {prov}/{region} that's ~{kg:.1f} kg CO2 "
|
|
113
|
+
f"at {reading.g_co2_per_kwh:.0f} gCO2/kWh ({reading.source}, {reading.quality}).")
|
|
114
|
+
else:
|
|
115
|
+
wanted = {provider} if provider else {"aws", "gcp", "azure"}
|
|
116
|
+
targets = [(prov, reg) for prov, reg, _meta in iter_regions() if prov in wanted]
|
|
117
|
+
# Fetch all regions' carbon CONCURRENTLY — live sources (esp. ENTSO-E with retries) are
|
|
118
|
+
# slow serially, and ranking needs every region before it can sort.
|
|
119
|
+
import asyncio
|
|
120
|
+
readings = await asyncio.gather(
|
|
121
|
+
*(carbon_service.carbon_now(repo, prov, reg) for prov, reg in targets) # type: ignore[arg-type]
|
|
122
|
+
)
|
|
123
|
+
options: list[TrainingRegionOption] = []
|
|
124
|
+
for (prov, reg), reading in zip(targets, readings):
|
|
125
|
+
kg = round(energy * reading.g_co2_per_kwh / 1000.0, 2)
|
|
126
|
+
options.append(TrainingRegionOption(
|
|
127
|
+
provider=prov, region=reg, # type: ignore[arg-type]
|
|
128
|
+
grid_intensity_g_co2_per_kwh=reading.g_co2_per_kwh,
|
|
129
|
+
carbon_source=reading.source, carbon_quality=reading.quality, est_kg_co2=kg,
|
|
130
|
+
))
|
|
131
|
+
if not options:
|
|
132
|
+
base.rationale = "No known regions matched the request."
|
|
133
|
+
return base
|
|
134
|
+
options.sort(key=lambda o: o.est_kg_co2) # greenest first
|
|
135
|
+
greenest = options[0]
|
|
136
|
+
dirtiest = options[-1]
|
|
137
|
+
# vs_baseline computed against the FULL set's dirtiest (the real worst case), so the
|
|
138
|
+
# headline "X% less than the dirtiest" reflects every region, not just the shown slice.
|
|
139
|
+
for o in options:
|
|
140
|
+
if dirtiest.est_kg_co2 > 0:
|
|
141
|
+
o.vs_baseline_pct = round((o.est_kg_co2 - dirtiest.est_kg_co2) / dirtiest.est_kg_co2 * 100, 1)
|
|
142
|
+
shown = options[:max(1, top_n_regions)]
|
|
143
|
+
_rate(shown) # R/Y/G relative to what's actually displayed
|
|
144
|
+
base.options = shown
|
|
145
|
+
base.recommended_region = f"{greenest.provider}/{greenest.region}"
|
|
146
|
+
chosen_prov, chosen_region = greenest.provider, greenest.region
|
|
147
|
+
saved_pct = abs(greenest.vs_baseline_pct) if greenest.vs_baseline_pct else 0.0
|
|
148
|
+
place_txt = (f"This run is ~{energy:.0f} kWh. Greenest option is {greenest.provider}/"
|
|
149
|
+
f"{greenest.region} at ~{greenest.est_kg_co2:.1f} kg CO2 - ~{saved_pct:.0f}% "
|
|
150
|
+
f"less than the dirtiest option ({dirtiest.provider}/{dirtiest.region}, "
|
|
151
|
+
f"~{dirtiest.est_kg_co2:.1f} kg).")
|
|
152
|
+
|
|
153
|
+
# --- Timing: reuse carbon-aware scheduling for the chosen/greenest region ---
|
|
154
|
+
# A run's wall-clock duration ~= total GPU-hours / GPUs running in parallel. If that's >= 2h,
|
|
155
|
+
# ask for the cleanest CONTIGUOUS window of that length (you start one job once), not scattered
|
|
156
|
+
# hours. Cap at 24 so it stays within the history window.
|
|
157
|
+
wall_clock_h = int(round(gpu_hours / max(1, gpu_count)))
|
|
158
|
+
window = wall_clock_h if 2 <= wall_clock_h <= 24 else None
|
|
159
|
+
advice = await scheduling_service.cleanest_hours(
|
|
160
|
+
repo, chosen_prov, chosen_region, top_n=3, window_hours=window,
|
|
161
|
+
)
|
|
162
|
+
base.cleanest_hours = advice.cleanest
|
|
163
|
+
base.cleanest_window = advice.window
|
|
164
|
+
base.timing_reduction_pct = advice.potential_reduction_pct
|
|
165
|
+
base.timing_source = advice.source
|
|
166
|
+
base.timing_quality = advice.quality
|
|
167
|
+
|
|
168
|
+
if advice.window and advice.window_reduction_pct and advice.window_reduction_pct >= 10:
|
|
169
|
+
w = advice.window
|
|
170
|
+
time_txt = (f" It runs ~{wall_clock_h}h wall-clock; the cleanest {w.window_hours}h window to "
|
|
171
|
+
f"start is {w.start_period} (avg ~{w.avg_g_co2_per_kwh:.0f} gCO2/kWh), "
|
|
172
|
+
f"~{advice.window_reduction_pct:.0f}% cleaner than the worst same-length window - "
|
|
173
|
+
f"training is deferrable, so this stacks on the region choice.")
|
|
174
|
+
elif advice.potential_reduction_pct and advice.potential_reduction_pct >= 10 and advice.cleanest:
|
|
175
|
+
best = advice.cleanest[0].period
|
|
176
|
+
time_txt = (f" Timing it to the cleanest hours in that region (around {best}) cuts a further "
|
|
177
|
+
f"~{advice.potential_reduction_pct:.0f}% vs the dirtiest hour - training is "
|
|
178
|
+
f"deferrable, so this stacks on the region choice.")
|
|
179
|
+
else:
|
|
180
|
+
time_txt = (" That region's grid is fairly flat over the window, so when you run matters "
|
|
181
|
+
"little there — the region choice is the lever.")
|
|
182
|
+
|
|
183
|
+
base.rationale = place_txt + time_txt
|
|
184
|
+
|
|
185
|
+
# Residency context: training usually means shipping a dataset INTO the recommended region.
|
|
186
|
+
# We don't know where the data lives, so we state the recommended region's jurisdiction as a
|
|
187
|
+
# neutral heads-up (not a verdict) — most pointed when it's inside the EEA.
|
|
188
|
+
jur = residency_service.jurisdiction_of(chosen_prov, chosen_region)
|
|
189
|
+
if jur:
|
|
190
|
+
where = residency_service._LABEL.get(jur, jur)
|
|
191
|
+
if jur == "EEA":
|
|
192
|
+
base.residency_note = (
|
|
193
|
+
f"RESIDENCY: the recommended region ({chosen_prov}/{chosen_region}) is in {where}. "
|
|
194
|
+
f"Shipping a training dataset in or out of the EEA can trigger GDPR transfer rules "
|
|
195
|
+
f"if it contains personal data. {residency_service.DISCLAIMER}")
|
|
196
|
+
else:
|
|
197
|
+
base.residency_note = (
|
|
198
|
+
f"RESIDENCY: the recommended region ({chosen_prov}/{chosen_region}) is in {where}. "
|
|
199
|
+
f"If your training data lives in another jurisdiction, moving it here may trigger "
|
|
200
|
+
f"residency rules. {residency_service.DISCLAIMER}")
|
|
201
|
+
return base
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _guess_provider(region: str) -> Optional[str]:
|
|
205
|
+
"""Find which provider a region code belongs to (region codes are unique across our catalog)."""
|
|
206
|
+
for prov, reg, _meta in iter_regions():
|
|
207
|
+
if reg == region:
|
|
208
|
+
return prov
|
|
209
|
+
return None
|