Skip to content

Commit d855916

Browse files
nathanmarlorclaude
andauthored
feat: show model download progress in aiod status (#3)
Track the model's download size (weights_gb) on the instance at spin time, then in `aiod status` pull live disk_usage + gpu_util from the provider and render a Download row, e.g. Download 239 / 343 GB (70%) — downloading weights Phase is inferred from telemetry (downloading -> loading into VRAM once the GPU is active) and the row hides once the model is serving. Older instances backfill weights_gb on first status via a sizing lookup. state.load() now drops unknown fields so an older binary won't lose a newer instance. Adds tests/test_state.py. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 44eb6fc commit d855916

5 files changed

Lines changed: 98 additions & 1 deletion

File tree

aiod/cli.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,7 @@ def spin(
458458
status="creating",
459459
provider=provider,
460460
idle_minutes=idle_m,
461+
weights_gb=plan.weights_gb,
461462
)
462463
state.save(inst)
463464
console.print(f"[green]✓[/] Instance [bold]{instance_id}[/] created. Waiting for boot...")
@@ -647,18 +648,33 @@ def status():
647648

648649
s = Settings.load()
649650
live_status = "?"
651+
disk_usage = gpu_util = None
650652
if providers.api_key_for(inst.provider, s):
651653
try:
652654
with providers.get_client(inst.provider, s) as client:
653655
vi = client.get_instance(inst.instance_id)
654656
live_status = client.status_of(vi)
657+
disk_usage = vi.get("disk_usage")
658+
gpu_util = vi.get("gpu_util")
655659
ep = client.endpoint_of(vi, CONTAINER_PORT)
656660
if ep and not inst.base_url:
657661
inst.host, inst.port = ep
658662
state.save(inst)
659663
except providers.PROVIDER_ERRORS as e:
660664
live_status = f"error: {e}"
661665

666+
# Backfill the download size for instances spun before it was tracked, so the
667+
# progress % still works. Best-effort: a sizing call hits HF, so only do it
668+
# while still loading and cache the result back to state.
669+
if inst.weights_gb is None and inst.status != "running" and disk_usage is not None:
670+
try:
671+
plan = size_any(inst.repo_id, hf_token=s.hf_token).plan(inst.quant)
672+
if plan:
673+
inst.weights_gb = plan.weights_gb
674+
state.save(inst)
675+
except Exception: # noqa: BLE001 - cosmetic; never break `status`
676+
pass
677+
662678
over = inst.expires_in_hours < 0
663679
table = Table(show_header=False)
664680
table.add_row("Instance", str(inst.instance_id))
@@ -674,6 +690,10 @@ def status():
674690
if over
675691
else f"{inst.expires_in_hours:.2f} h left",
676692
)
693+
if inst.status != "running":
694+
dl = state.download_progress(disk_usage, inst.weights_gb, gpu_util)
695+
if dl:
696+
table.add_row("Download", dl)
677697
console.print(table)
678698
if evs:
679699
_print_events(evs)

aiod/engine.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ def emit(phase: str, msg: str = "") -> None:
9696
status="creating",
9797
provider=provider,
9898
idle_minutes=idle_minutes,
99+
weights_gb=plan.weights_gb,
99100
)
100101
state.save(inst)
101102

aiod/state.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from __future__ import annotations
66

7+
import dataclasses
78
import json
89
import time
910
from dataclasses import asdict, dataclass
@@ -30,6 +31,7 @@ class Instance:
3031
status: str = "creating" # creating | loading | running | error
3132
provider: str = "vast" # which backend rented this (vast | runpod | ...)
3233
idle_minutes: int | None = None # auto-shutdown threshold, if a watcher is running
34+
weights_gb: float | None = None # model download size, for download-progress %
3335

3436
@property
3537
def base_url(self) -> str | None:
@@ -50,6 +52,27 @@ def est_cost_so_far(self) -> float:
5052
return self.age_hours * self.price_per_hr
5153

5254

55+
def download_progress(
56+
disk_usage_gb: float | None, weights_gb: float | None, gpu_util: float | None = None
57+
) -> str | None:
58+
"""A human 'Download' line from live telemetry, e.g.
59+
'135 / 343 GB (39%) — downloading weights'. Returns None when there's no
60+
usable figure. `disk_usage_gb` counts the container image too, so the % runs a
61+
little optimistic; GPU activity is what tells us the load phase has started."""
62+
if disk_usage_gb is None:
63+
return None
64+
if not weights_gb:
65+
return f"{disk_usage_gb:.0f} GB on disk"
66+
pct = min(100.0, disk_usage_gb / weights_gb * 100)
67+
if gpu_util and gpu_util > 0:
68+
phase = "loading into VRAM"
69+
elif pct >= 99:
70+
phase = "download complete — loading"
71+
else:
72+
phase = "downloading weights"
73+
return f"{disk_usage_gb:.0f} / {weights_gb:.0f} GB ({pct:.0f}%) — {phase}"
74+
75+
5376
def save(inst: Instance) -> None:
5477
STATE_DIR.mkdir(parents=True, exist_ok=True)
5578
STATE_FILE.write_text(json.dumps(asdict(inst), indent=2))
@@ -60,7 +83,10 @@ def load() -> Instance | None:
6083
return None
6184
try:
6285
data = json.loads(STATE_FILE.read_text())
63-
return Instance(**data)
86+
# Tolerate fields written by a newer version (drop unknowns) so an older
87+
# binary doesn't lose track of a live instance.
88+
fields = {f.name for f in dataclasses.fields(Instance)}
89+
return Instance(**{k: v for k, v in data.items() if k in fields})
6490
except (json.JSONDecodeError, TypeError):
6591
return None
6692

aiod/tui.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,7 @@ def _launch_worker(self, quant: str, best: PricedOption, provider: str, idle_m:
374374
status="creating",
375375
provider=provider,
376376
idle_minutes=idle_m,
377+
weights_gb=(p.weights_gb if (p := self.sizing.plan(quant)) else None),
377378
)
378379
state.save(inst)
379380
self.call_from_thread(self._log, f"[green]✓[/] Instance {instance_id} created.")

tests/test_state.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from aiod import state
2+
3+
4+
def test_download_progress_percent_and_phase():
5+
line = state.download_progress(135, 343, gpu_util=0)
6+
assert "135 / 343 GB" in line
7+
assert "(39%)" in line
8+
assert "downloading weights" in line
9+
10+
11+
def test_download_progress_gpu_active_means_loading():
12+
line = state.download_progress(343, 343, gpu_util=42)
13+
assert "loading into VRAM" in line
14+
15+
16+
def test_download_progress_complete_but_idle_gpu():
17+
line = state.download_progress(343, 343, gpu_util=0)
18+
assert "(100%)" in line
19+
assert "download complete" in line
20+
21+
22+
def test_download_progress_caps_at_100():
23+
# disk_usage counts the image too, so it can exceed the weights size.
24+
line = state.download_progress(360, 343, gpu_util=0)
25+
assert "(100%)" in line
26+
27+
28+
def test_download_progress_no_target_size():
29+
assert state.download_progress(80, None) == "80 GB on disk"
30+
31+
32+
def test_download_progress_no_telemetry():
33+
assert state.download_progress(None, 343) is None
34+
35+
36+
def test_load_tolerates_unknown_fields(tmp_path, monkeypatch):
37+
monkeypatch.setattr(state, "STATE_DIR", tmp_path)
38+
monkeypatch.setattr(state, "STATE_FILE", tmp_path / "state.json")
39+
inst = state.Instance(
40+
instance_id=1, repo_id="org/m", quant="bf16", gpu_desc="1x X",
41+
price_per_hr=1.0, created_at=0.0, ttl_hours=4.0, weights_gb=343.0,
42+
)
43+
state.save(inst)
44+
# Simulate a field written by a newer version.
45+
raw = (tmp_path / "state.json").read_text().rstrip().rstrip("}")
46+
(tmp_path / "state.json").write_text(raw + ', "future_field": 99\n}')
47+
loaded = state.load()
48+
assert loaded is not None
49+
assert loaded.weights_gb == 343.0

0 commit comments

Comments
 (0)