|
27 | 27 | from __future__ import annotations |
28 | 28 |
|
29 | 29 | import argparse |
| 30 | +import json |
30 | 31 | import os |
31 | 32 | import subprocess |
32 | 33 | import sys |
@@ -65,8 +66,26 @@ def _get_carbon_client(): |
65 | 66 |
|
66 | 67 |
|
67 | 68 | def _get_power_control(): |
68 | | - from efficiency.power_control import get_default_power_limit |
69 | | - return get_default_power_limit |
| 69 | + from efficiency.power_control import get_default_power_limit, set_power_limit |
| 70 | + return get_default_power_limit, set_power_limit |
| 71 | + |
| 72 | + |
| 73 | +# ── Cloud cost lookup ──────────────────────────────────────────────────────── |
| 74 | + |
| 75 | +# Approximate spot/on-demand rates ($/hr) for common cloud GPUs |
| 76 | +CLOUD_RATES_PER_HOUR: dict[str, float] = { |
| 77 | + "H100": 3.99, "H200": 5.49, "A100": 1.89, |
| 78 | + "RTX 4090": 0.59, "RTX 3090": 0.44, "L40S": 1.14, |
| 79 | + "L40": 0.89, "A10G": 0.50, "T4": 0.20, "V100": 0.80, |
| 80 | +} |
| 81 | + |
| 82 | + |
| 83 | +def _lookup_cloud_rate(gpu_name: str) -> float | None: |
| 84 | + """Match GPU name to cloud hourly rate via substring.""" |
| 85 | + for key, rate in CLOUD_RATES_PER_HOUR.items(): |
| 86 | + if key in gpu_name: |
| 87 | + return rate |
| 88 | + return None |
70 | 89 |
|
71 | 90 |
|
72 | 91 | # ── Console helpers ────────────────────────────────────────────────────────── |
@@ -109,11 +128,28 @@ def demo_1_powercap(gpu: int, duration: int, iterations: int, warmup: int) -> di |
109 | 128 | """'Free Money' power cap test — same workload, lower TDP.""" |
110 | 129 | _banner(1, 5, '"Free Money" Power Cap A/B') |
111 | 130 |
|
112 | | - get_default = _get_power_control() |
| 131 | + get_default, set_limit = _get_power_control() |
113 | 132 | try: |
114 | 133 | default_w = get_default(gpu) |
115 | 134 | except Exception: |
116 | 135 | default_w = 450 # RTX 4090 fallback |
| 136 | + |
| 137 | + # Pre-flight: check if power capping is available |
| 138 | + can_cap = set_limit(gpu, default_w, quiet=True) |
| 139 | + if not can_cap: |
| 140 | + if _rich: |
| 141 | + Console().print(Panel( |
| 142 | + "[bold yellow]Power capping not available on this platform[/bold yellow]\n" |
| 143 | + "Cloud containers typically block GPU power limit changes.\n" |
| 144 | + "This demo requires bare-metal or on-prem GPU access.\n" |
| 145 | + "Skipping Demo 1.", |
| 146 | + border_style="yellow", |
| 147 | + )) |
| 148 | + else: |
| 149 | + print(" Power capping not available on this platform (cloud container).") |
| 150 | + print(" Skipping Demo 1.") |
| 151 | + return None |
| 152 | + |
117 | 153 | capped_w = int(default_w * 0.70) |
118 | 154 |
|
119 | 155 | print(f" Default TDP: {default_w}W") |
@@ -230,23 +266,44 @@ def demo_3_idle_waste(gpu: int, sample_duration: int = 15) -> dict | None: |
230 | 266 | avg_power = result.avg_power_w |
231 | 267 | idle_fraction = result.idle_fraction |
232 | 268 | monthly_waste_kwh = (avg_power / 1000.0) * 720 # kWh per month |
233 | | - monthly_waste_usd = monthly_waste_kwh * 0.12 |
| 269 | + electricity_waste_usd = monthly_waste_kwh * 0.12 |
| 270 | + |
| 271 | + # Cloud instance cost (the real number) |
| 272 | + cloud_rate = _lookup_cloud_rate(gpu_name) |
| 273 | + cloud_monthly = cloud_rate * 720 if cloud_rate else None |
234 | 274 |
|
235 | 275 | from optimize import _print_rich as opt_rich, _print_plain as opt_plain |
236 | 276 | if _rich: |
237 | 277 | opt_rich(result) |
238 | 278 | console = Console() |
239 | 279 | console.print() |
240 | | - console.print(Panel( |
241 | | - f"[bold red]This GPU is burning ${monthly_waste_usd:.2f}/month doing nothing[/bold red]\n" |
| 280 | + |
| 281 | + waste_lines = [] |
| 282 | + if cloud_monthly: |
| 283 | + waste_lines.append( |
| 284 | + f"[bold red]This GPU is burning ${cloud_monthly:,.0f}/month in cloud costs doing nothing[/bold red]" |
| 285 | + ) |
| 286 | + waste_lines.append( |
| 287 | + f"Cloud instance: ${cloud_rate:.2f}/hr = [bold]${cloud_monthly:,.0f}/month[/bold] | " |
| 288 | + f"Electricity: ${electricity_waste_usd:.2f}/month" |
| 289 | + ) |
| 290 | + else: |
| 291 | + waste_lines.append( |
| 292 | + f"[bold red]This GPU is burning ${electricity_waste_usd:.2f}/month doing nothing[/bold red]" |
| 293 | + ) |
| 294 | + waste_lines.append( |
242 | 295 | f"Idle fraction: {idle_fraction*100:.0f}% | " |
243 | 296 | f"Avg power: {avg_power:.0f}W | " |
244 | | - f"Monthly waste: {monthly_waste_kwh:.0f} kWh", |
245 | | - border_style="red", |
246 | | - )) |
| 297 | + f"Monthly waste: {monthly_waste_kwh:.0f} kWh" |
| 298 | + ) |
| 299 | + console.print(Panel("\n".join(waste_lines), border_style="red")) |
247 | 300 | else: |
248 | 301 | opt_plain(result) |
249 | | - print(f"\n >>> This GPU is burning ${monthly_waste_usd:.2f}/month doing nothing") |
| 302 | + if cloud_monthly: |
| 303 | + print(f"\n >>> This GPU is burning ${cloud_monthly:,.0f}/month in cloud costs doing nothing") |
| 304 | + print(f" Cloud: ${cloud_rate:.2f}/hr = ${cloud_monthly:,.0f}/mo | Electricity: ${electricity_waste_usd:.2f}/mo") |
| 305 | + else: |
| 306 | + print(f"\n >>> This GPU is burning ${electricity_waste_usd:.2f}/month doing nothing") |
250 | 307 | print(f" Idle: {idle_fraction*100:.0f}% | Power: {avg_power:.0f}W | Waste: {monthly_waste_kwh:.0f} kWh/mo") |
251 | 308 |
|
252 | 309 | return asdict(result) |
@@ -369,8 +426,18 @@ def run_demo(args: argparse.Namespace) -> int: |
369 | 426 | ab_results.append(r) |
370 | 427 |
|
371 | 428 | if demo_choice in ("all", "5"): |
| 429 | + # Load from file if provided |
| 430 | + if hasattr(args, "result_file") and args.result_file: |
| 431 | + try: |
| 432 | + with open(args.result_file) as f: |
| 433 | + ab_results = [json.load(f)] |
| 434 | + except (FileNotFoundError, json.JSONDecodeError) as e: |
| 435 | + print(f" ERROR: Could not load result file: {e}") |
| 436 | + return 1 |
| 437 | + |
372 | 438 | if not ab_results: |
373 | | - print(" Demo 5 requires results from Demos 1, 2, or 4. Run --demo all first.") |
| 439 | + print(" Demo 5 requires results from Demos 1, 2, or 4.") |
| 440 | + print(" Run --demo all first, or provide --result-file PATH.") |
374 | 441 | else: |
375 | 442 | demo_5_scaling(ab_results) |
376 | 443 |
|
@@ -402,4 +469,6 @@ def make_parser() -> argparse.ArgumentParser: |
402 | 469 | help="Quick mode: 30s, 1 iteration (default)") |
403 | 470 | p.add_argument("--full", action="store_true", default=False, |
404 | 471 | help="Full mode: 120s, 3 iterations") |
| 472 | + p.add_argument("--result-file", type=str, metavar="PATH", |
| 473 | + help="Path to ABResult JSON file (for Demo 5 standalone)") |
405 | 474 | return p |
0 commit comments