flashruntime 0.3.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.
- flashml_workloads/__init__.py +7 -0
- flashml_workloads/fedavg_driver.py +569 -0
- flashml_workloads/fedavg_weights.py +223 -0
- flashml_workloads/fedavg_worker.py +166 -0
- flashml_workloads/kmeans_driver.py +134 -0
- flashml_workloads/kmeans_shard.py +69 -0
- flashml_workloads/sgd_trainer.py +127 -0
- flashml_workloads/sharded_kmeans.py +323 -0
- flashml_workloads/sklearn_trial.py +89 -0
- flashruntime/__init__.py +125 -0
- flashruntime/artifacts/__init__.py +25 -0
- flashruntime/artifacts/store.py +228 -0
- flashruntime/backends/__init__.py +26 -0
- flashruntime/backends/base.py +63 -0
- flashruntime/backends/kuberay.py +465 -0
- flashruntime/checkpoint/__init__.py +20 -0
- flashruntime/checkpoint/catalog.py +198 -0
- flashruntime/checkpoint/local.py +109 -0
- flashruntime/checkpoint/store.py +86 -0
- flashruntime/integrations/__init__.py +5 -0
- flashruntime/integrations/huggingface.py +59 -0
- flashruntime/integrations/pytorch.py +52 -0
- flashruntime/integrations/sklearn.py +42 -0
- flashruntime/launchers/__init__.py +130 -0
- flashruntime/launchers/local.py +126 -0
- flashruntime/leases/__init__.py +27 -0
- flashruntime/leases/manager.py +365 -0
- flashruntime/leases/sqlite_store.py +169 -0
- flashruntime/leases/store.py +103 -0
- flashruntime/monitor/__init__.py +7 -0
- flashruntime/monitor/sampler.py +232 -0
- flashruntime/planner/__init__.py +56 -0
- flashruntime/planner/candidates.py +597 -0
- flashruntime/planner/catalog.py +129 -0
- flashruntime/planner/comm.py +95 -0
- flashruntime/planner/explain.py +109 -0
- flashruntime/planner/memory.py +166 -0
- flashruntime/planner/resolve.py +120 -0
- flashruntime/planner/selector.py +169 -0
- flashruntime/planner/timecost.py +81 -0
- flashruntime/profiling/__init__.py +113 -0
- flashruntime/protocol/__init__.py +18 -0
- flashruntime/protocol/plan_v1alpha1.py +320 -0
- flashruntime/protocol/v1alpha1.py +465 -0
- flashruntime/providers/__init__.py +138 -0
- flashruntime/py.typed +0 -0
- flashruntime/recipes/__init__.py +135 -0
- flashruntime/recipes/command.py +166 -0
- flashruntime/recovery/__init__.py +21 -0
- flashruntime/recovery/policy.py +170 -0
- flashruntime/recovery/signals.py +135 -0
- flashruntime/recovery/taxonomy.py +91 -0
- flashruntime/scheduler/__init__.py +170 -0
- flashruntime/sdk.py +402 -0
- flashruntime/service/__init__.py +3 -0
- flashruntime/service/app.py +391 -0
- flashruntime/service/auth.py +180 -0
- flashruntime/service/checkpoints.py +90 -0
- flashruntime/service/cli.py +167 -0
- flashruntime/service/dashboard.py +193 -0
- flashruntime/service/ledger.py +101 -0
- flashruntime/service/modea.py +821 -0
- flashruntime/strategies/__init__.py +156 -0
- flashruntime/strategies/command.py +56 -0
- flashruntime/torch/__init__.py +274 -0
- flashruntime/viewer/__init__.py +20 -0
- flashruntime/viewer/_docs/benchmarks.html +771 -0
- flashruntime/viewer/_docs/concepts/architecture.html +302 -0
- flashruntime/viewer/_docs/get-started.html +263 -0
- flashruntime/viewer/_docs/guides/federated-averaging.html +363 -0
- flashruntime/viewer/_docs/guides/huggingface.html +223 -0
- flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +271 -0
- flashruntime/viewer/_docs/guides/pytorch.html +313 -0
- flashruntime/viewer/_docs/guides/sklearn.html +232 -0
- flashruntime/viewer/_docs/index.html +251 -0
- flashruntime/viewer/_docs/reference/cli.html +254 -0
- flashruntime/viewer/_docs/reference/integrations.html +240 -0
- flashruntime/viewer/_docs/reference/sdk.html +341 -0
- flashruntime/viewer/_docs/reference/torch-helper.html +244 -0
- flashruntime/viewer/_docs/search-index.json +1 -0
- flashruntime/viewer/_docs/tutorials/convnet.html +571 -0
- flashruntime/viewer/_docs/tutorials/fault-tolerance.html +375 -0
- flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +278 -0
- flashruntime/viewer/flowmap.py +307 -0
- flashruntime/viewer/page.py +594 -0
- flashruntime/viewer/server.py +134 -0
- flashruntime/viewer/state.py +250 -0
- flashruntime/workloads/__init__.py +6 -0
- flashruntime/workloads/command.py +127 -0
- flashruntime-0.3.0.dist-info/METADATA +365 -0
- flashruntime-0.3.0.dist-info/RECORD +95 -0
- flashruntime-0.3.0.dist-info/WHEEL +5 -0
- flashruntime-0.3.0.dist-info/entry_points.txt +2 -0
- flashruntime-0.3.0.dist-info/licenses/LICENSE +202 -0
- flashruntime-0.3.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""RunViewerServer — the read-only HTTP surface over one run directory.
|
|
2
|
+
|
|
3
|
+
A stdlib `ThreadingHTTPServer` a `flash.submit(watch=True)` opens on the run's
|
|
4
|
+
output_dir. It serves exactly four things and 404s everything else:
|
|
5
|
+
|
|
6
|
+
GET / → the live run page (viewer.page.render(); polls /api/state)
|
|
7
|
+
GET /api/state → state.collect(run_dir) as JSON (the live snapshot)
|
|
8
|
+
GET /docs, /docs/…→ static files under the packaged _docs/ dir, else 404
|
|
9
|
+
(anything else) → 404
|
|
10
|
+
|
|
11
|
+
Threading, not a single-thread loop: the page polls /api/state every couple
|
|
12
|
+
of seconds while a human also clicks around; a blocking handler would stall
|
|
13
|
+
one request behind another. Everything is bound to 127.0.0.1 — this is a
|
|
14
|
+
personal, local viewer, never a network service.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import mimetypes
|
|
21
|
+
import threading
|
|
22
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from urllib.parse import urlparse
|
|
25
|
+
|
|
26
|
+
from flashruntime.viewer.page import render as render_page
|
|
27
|
+
from flashruntime.viewer.state import collect
|
|
28
|
+
|
|
29
|
+
# Packaged docs live here once Task 8's builder has run; absent in a fresh
|
|
30
|
+
# checkout, which is why /docs degrades to an honest "docs not built" 404.
|
|
31
|
+
_DEFAULT_DOCS_DIR = Path(__file__).parent / "_docs"
|
|
32
|
+
|
|
33
|
+
# The run page is a static document (all liveness comes from its /api/state
|
|
34
|
+
# polling), so render it once and reuse the bytes for every GET /.
|
|
35
|
+
_PAGE_BYTES = render_page().encode()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
39
|
+
"""Routes GETs for one run. Reads its run_dir/docs_dir off the server
|
|
40
|
+
instance (`self.server`), so a single handler class serves any run."""
|
|
41
|
+
|
|
42
|
+
# Silence the default per-request stderr line: the user launched this from
|
|
43
|
+
# the same terminal their training logs to, and access-log spam would bury
|
|
44
|
+
# that output. (The viewer is deliberately quiet.)
|
|
45
|
+
def log_message(self, *args) -> None: # noqa: D401
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
def do_GET(self) -> None:
|
|
49
|
+
path = urlparse(self.path).path
|
|
50
|
+
if path == "/":
|
|
51
|
+
self._send(200, "text/html; charset=utf-8", _PAGE_BYTES)
|
|
52
|
+
elif path == "/api/state":
|
|
53
|
+
# collect() is total (never raises), so this branch cannot 500 on
|
|
54
|
+
# any on-disk state — it returns a snapshot or an {"error": ...}.
|
|
55
|
+
body = json.dumps(self.server.run_state()).encode()
|
|
56
|
+
self._send(200, "application/json; charset=utf-8", body)
|
|
57
|
+
elif path == "/docs" or path.startswith("/docs/"):
|
|
58
|
+
self._serve_docs(path)
|
|
59
|
+
else:
|
|
60
|
+
self._send(404, "text/plain; charset=utf-8", b"not found")
|
|
61
|
+
|
|
62
|
+
def _serve_docs(self, path: str) -> None:
|
|
63
|
+
docs_dir: Path = self.server.docs_dir
|
|
64
|
+
if not docs_dir.is_dir():
|
|
65
|
+
# The builder (Task 8) has not run in this checkout.
|
|
66
|
+
self._send(404, "text/plain; charset=utf-8", b"docs not built")
|
|
67
|
+
return
|
|
68
|
+
rel = path[len("/docs"):].lstrip("/") or "index.html" # /docs and /docs/ → index.html
|
|
69
|
+
target = (docs_dir / rel).resolve()
|
|
70
|
+
# PATH-TRAVERSAL GUARD: `rel` comes straight from the request line, so
|
|
71
|
+
# a crafted "/docs/../../etc/passwd" would otherwise resolve OUTSIDE
|
|
72
|
+
# docs_dir. Reject anything whose resolved path is not under the docs
|
|
73
|
+
# root — serving files by request path without this check is a hole.
|
|
74
|
+
if not target.is_relative_to(docs_dir.resolve()) or not target.is_file():
|
|
75
|
+
self._send(404, "text/plain; charset=utf-8", b"not found")
|
|
76
|
+
return
|
|
77
|
+
ctype = mimetypes.guess_type(str(target))[0] or "application/octet-stream"
|
|
78
|
+
try:
|
|
79
|
+
body = target.read_bytes()
|
|
80
|
+
except OSError:
|
|
81
|
+
self._send(404, "text/plain; charset=utf-8", b"not found")
|
|
82
|
+
return
|
|
83
|
+
self._send(200, ctype, body)
|
|
84
|
+
|
|
85
|
+
def _send(self, code: int, ctype: str, body: bytes) -> None:
|
|
86
|
+
self.send_response(code)
|
|
87
|
+
self.send_header("Content-Type", ctype)
|
|
88
|
+
self.send_header("Content-Length", str(len(body)))
|
|
89
|
+
self.end_headers()
|
|
90
|
+
self.wfile.write(body)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class RunViewerServer:
|
|
94
|
+
"""Serve one run directory over HTTP on 127.0.0.1.
|
|
95
|
+
|
|
96
|
+
`port=0` (the default) asks the OS for a free port, so opening a viewer
|
|
97
|
+
never collides with another run's. `.start()` binds, spawns a daemon
|
|
98
|
+
serve loop, and returns the URL; `.stop()` shuts the loop down and closes
|
|
99
|
+
the socket (frees the port). `docs_dir` defaults to the packaged _docs/
|
|
100
|
+
but is injectable — the traversal guard and the docs routes are only
|
|
101
|
+
testable against a real directory, and the package's own _docs/ does not
|
|
102
|
+
exist until Task 8 builds it.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, run_dir: Path, port: int = 0, docs_dir: Path | None = None):
|
|
106
|
+
self.run_dir = Path(run_dir)
|
|
107
|
+
self.docs_dir = Path(docs_dir) if docs_dir is not None else _DEFAULT_DOCS_DIR
|
|
108
|
+
self._requested_port = port
|
|
109
|
+
self._httpd: ThreadingHTTPServer | None = None
|
|
110
|
+
self._thread: threading.Thread | None = None
|
|
111
|
+
self.url: str | None = None
|
|
112
|
+
|
|
113
|
+
def start(self) -> str:
|
|
114
|
+
httpd = ThreadingHTTPServer(("127.0.0.1", self._requested_port), _Handler)
|
|
115
|
+
# Hand the handler its per-server config. run_state is a bound method
|
|
116
|
+
# so each request re-reads the run dir fresh (a live snapshot).
|
|
117
|
+
httpd.run_state = lambda: collect(self.run_dir) # type: ignore[attr-defined]
|
|
118
|
+
httpd.docs_dir = self.docs_dir # type: ignore[attr-defined]
|
|
119
|
+
httpd.daemon_threads = True # worker threads never block interpreter exit
|
|
120
|
+
self._httpd = httpd
|
|
121
|
+
port = httpd.server_address[1] # the OS-assigned port when we asked for 0
|
|
122
|
+
self._thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
|
123
|
+
self._thread.start()
|
|
124
|
+
self.url = f"http://127.0.0.1:{port}"
|
|
125
|
+
return self.url
|
|
126
|
+
|
|
127
|
+
def stop(self) -> None:
|
|
128
|
+
if self._httpd is not None:
|
|
129
|
+
self._httpd.shutdown() # stop serve_forever
|
|
130
|
+
self._httpd.server_close() # close the listening socket → port freed
|
|
131
|
+
self._httpd = None
|
|
132
|
+
if self._thread is not None:
|
|
133
|
+
self._thread.join(timeout=2)
|
|
134
|
+
self._thread = None
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""state.collect(run_dir) — assemble the /api/state snapshot from disk.
|
|
2
|
+
|
|
3
|
+
WHY EXCEPTION-SAFETY IS THE WHOLE POINT: this reads a *live* run's directory
|
|
4
|
+
while the SDK is still writing into it. metrics.jsonl grows an unterminated
|
|
5
|
+
last line between our read and the writer's flush; a manifest may be
|
|
6
|
+
half-written; an attempt's output_dir may not exist yet; a file may be
|
|
7
|
+
unreadable. A viewer that raised on any of these would turn "someone glanced
|
|
8
|
+
at the run" into "the run's dashboard 500'd" — and worse, invite the reflex
|
|
9
|
+
to make the SDK slow down or lock for the reader. So collect() is READ-ONLY
|
|
10
|
+
and TOTAL: every disk hazard degrades to a partial snapshot (an empty
|
|
11
|
+
section, a skipped record, an `"error"` string) and NEVER an exception. The
|
|
12
|
+
run's story is never interrupted by being watched.
|
|
13
|
+
|
|
14
|
+
Data model: pass the parsed `viewer_v1` run.json through verbatim, then
|
|
15
|
+
ENRICH it — each attempt gains its own `metrics` (metrics.jsonl tail) and
|
|
16
|
+
`log_tail` (launcher.log tail), `telemetry` (sampler tail) and `ranks`
|
|
17
|
+
(per-rank heartbeats), read from that attempt's `output_dir`, and a
|
|
18
|
+
top-level `checkpoints` list is assembled from the job ckpt roots (each root
|
|
19
|
+
is the sibling `ckpt/` dir of an attempt dir). A top-level `monitor` object
|
|
20
|
+
carries the newest telemetry sample (machine stats + process tree).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import io
|
|
26
|
+
import json
|
|
27
|
+
import time
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
_METRICS_TAIL = 500 # last N points per attempt — enough for a loss curve, bounded memory
|
|
31
|
+
_LOG_TAIL = 100 # last N lines of launcher.log — a glance at what the process last said
|
|
32
|
+
_TELEMETRY_TAIL = 150 # last N sampler ticks — a ~5 min usage chart at 2 s period
|
|
33
|
+
|
|
34
|
+
# The tail window we seek-and-read from the END of a growing log. WHY BOUNDED:
|
|
35
|
+
# the viewer re-reads these files every ~2 s against a LIVE run; a chatty
|
|
36
|
+
# trainer's launcher.log / metrics.jsonl can reach multiple GB, and slurping
|
|
37
|
+
# the whole file each poll would blow memory and thrash the disk against the
|
|
38
|
+
# writer — the very interference this module promises not to cause. 256 KiB
|
|
39
|
+
# comfortably holds the last 500 metrics records (~0.5 KiB each) or 100 log
|
|
40
|
+
# lines of any sane width, so the wanted tail always lands inside one window.
|
|
41
|
+
_TAIL_WINDOW = 256 * 1024
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def collect(run_dir: Path) -> dict:
|
|
45
|
+
"""Read `<run_dir>/run.json` (the viewer_v1 contract) and return the
|
|
46
|
+
enriched /api/state dict. Never raises: an outer backstop converts any
|
|
47
|
+
unforeseen failure into `{"error": ...}` so a viewer can never crash a
|
|
48
|
+
live run — see the module docstring."""
|
|
49
|
+
try:
|
|
50
|
+
return _collect(Path(run_dir))
|
|
51
|
+
except Exception as exc: # noqa: BLE001 — total by contract; see module docstring
|
|
52
|
+
return {"error": f"viewer snapshot failed: {exc!r}"}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _collect(run_dir: Path) -> dict:
|
|
56
|
+
run_json = run_dir / "run.json"
|
|
57
|
+
try:
|
|
58
|
+
raw = run_json.read_text(errors="replace")
|
|
59
|
+
except OSError as exc:
|
|
60
|
+
return {"error": f"run.json not found: {exc}"}
|
|
61
|
+
try:
|
|
62
|
+
doc = json.loads(raw)
|
|
63
|
+
except ValueError as exc:
|
|
64
|
+
# A torn read (writer mid-os.replace is atomic, but a truncated file
|
|
65
|
+
# from any other cause still lands here) yields an error, not a crash.
|
|
66
|
+
return {"error": f"run.json unreadable (torn or invalid JSON): {exc}"}
|
|
67
|
+
contract = doc.get("contract") if isinstance(doc, dict) else None
|
|
68
|
+
if contract != "viewer_v1":
|
|
69
|
+
# Fail closed on an unknown contract: a future/foreign schema is not
|
|
70
|
+
# something this version can honestly render, so say so rather than
|
|
71
|
+
# guess at fields that may have moved.
|
|
72
|
+
return {"error": f"unknown run.json contract: {contract!r}"}
|
|
73
|
+
|
|
74
|
+
# Enrich each attempt with its own metrics + log tail (both live in the
|
|
75
|
+
# attempt's output_dir), and collect the distinct job ckpt roots as we go.
|
|
76
|
+
ckpt_roots: dict[str, Path] = {}
|
|
77
|
+
enriched: list[dict] = []
|
|
78
|
+
for attempt in doc.get("attempts") or []:
|
|
79
|
+
row = dict(attempt)
|
|
80
|
+
out = row.get("output_dir")
|
|
81
|
+
attempt_dir = Path(out) if out else None
|
|
82
|
+
row["metrics"] = _metrics_tail(attempt_dir) if attempt_dir else []
|
|
83
|
+
row["log_tail"] = _log_tail(attempt_dir) if attempt_dir else ""
|
|
84
|
+
row["telemetry"] = _telemetry_tail(attempt_dir) if attempt_dir else []
|
|
85
|
+
row["ranks"] = _ranks(attempt_dir) if attempt_dir else []
|
|
86
|
+
enriched.append(row)
|
|
87
|
+
if attempt_dir is not None:
|
|
88
|
+
# The job ckpt root is the sibling `ckpt/` dir of the attempt dir
|
|
89
|
+
# (`<run>/<job>/<attempt>` → `<run>/<job>/ckpt`); restarts share a
|
|
90
|
+
# job_id, so many attempts map to one root — dedupe by path.
|
|
91
|
+
root = attempt_dir.parent / "ckpt"
|
|
92
|
+
ckpt_roots[str(root)] = root
|
|
93
|
+
doc["attempts"] = enriched
|
|
94
|
+
|
|
95
|
+
manifests: list[dict] = []
|
|
96
|
+
for root in ckpt_roots.values():
|
|
97
|
+
try:
|
|
98
|
+
manifests.extend(_manifests_for_root(root))
|
|
99
|
+
except Exception: # noqa: BLE001 — one bad root never drops the others
|
|
100
|
+
continue
|
|
101
|
+
manifests.sort(key=lambda m: (m.get("job_id", ""), m.get("step", 0)))
|
|
102
|
+
doc["checkpoints"] = manifests
|
|
103
|
+
|
|
104
|
+
# The newest telemetry sample anywhere becomes the machine-level `monitor`
|
|
105
|
+
# object (KPI tiles + machine node read it). Newest-by-ts, not "the
|
|
106
|
+
# running attempt's", so a just-finished run still shows its last stats.
|
|
107
|
+
monitor = None
|
|
108
|
+
for row in enriched:
|
|
109
|
+
tel = row.get("telemetry") or []
|
|
110
|
+
cand = tel[-1] if tel else None
|
|
111
|
+
if isinstance(cand, dict) and (monitor is None or cand.get("ts", 0) > monitor.get("ts", 0)):
|
|
112
|
+
monitor = cand
|
|
113
|
+
doc["monitor"] = monitor
|
|
114
|
+
return doc
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _tail_window_lines(path: Path) -> list[str]:
|
|
118
|
+
"""Return the whole lines contained in the last `_TAIL_WINDOW` bytes of
|
|
119
|
+
`path` — a bounded seek+read that NEVER loads the whole file (see the
|
|
120
|
+
`_TAIL_WINDOW` note). Total: any OSError (missing / dir gone / unreadable)
|
|
121
|
+
degrades to `[]`, never an exception."""
|
|
122
|
+
try:
|
|
123
|
+
with path.open("rb") as fh:
|
|
124
|
+
fh.seek(0, io.SEEK_END)
|
|
125
|
+
size = fh.tell()
|
|
126
|
+
# Read a window ending at EOF rather than from byte 0: on a
|
|
127
|
+
# multi-GB log this is one bounded read, not a full slurp. When the
|
|
128
|
+
# file is smaller than the window, start clamps to 0 (whole file).
|
|
129
|
+
start = max(0, size - _TAIL_WINDOW)
|
|
130
|
+
fh.seek(start)
|
|
131
|
+
window = fh.read()
|
|
132
|
+
except OSError:
|
|
133
|
+
return []
|
|
134
|
+
lines = window.decode("utf-8", errors="replace").splitlines()
|
|
135
|
+
# If we began mid-file (start > 0), the first "line" is a torn fragment of
|
|
136
|
+
# the record straddling the window's start byte — drop it so only whole
|
|
137
|
+
# lines escape (a partial JSON metrics line would skip on parse anyway).
|
|
138
|
+
if start > 0 and lines:
|
|
139
|
+
lines = lines[1:]
|
|
140
|
+
return lines
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _metrics_tail(attempt_dir: Path, limit: int = _METRICS_TAIL) -> list[dict]:
|
|
144
|
+
"""Last `limit` JSON records from `metrics.jsonl`, keys passed through
|
|
145
|
+
as-is. A half-written final line (the writer appended between our read and
|
|
146
|
+
its newline) fails to parse and is skipped — the earlier points survive.
|
|
147
|
+
Reads only the bounded tail window, never the whole file."""
|
|
148
|
+
records: list[dict] = []
|
|
149
|
+
for line in _tail_window_lines(attempt_dir / "metrics.jsonl"):
|
|
150
|
+
line = line.strip()
|
|
151
|
+
if not line:
|
|
152
|
+
continue
|
|
153
|
+
try:
|
|
154
|
+
rec = json.loads(line)
|
|
155
|
+
except ValueError:
|
|
156
|
+
continue # torn/partial line — skip it, keep the rest
|
|
157
|
+
if isinstance(rec, dict):
|
|
158
|
+
records.append(rec)
|
|
159
|
+
return records[-limit:]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _telemetry_tail(attempt_dir: Path, limit: int = _TELEMETRY_TAIL) -> list[dict]:
|
|
163
|
+
"""Last `limit` sampler ticks from `telemetry.jsonl` — the same bounded
|
|
164
|
+
tail-window + skip-torn-lines discipline as `_metrics_tail` (the sampler
|
|
165
|
+
appends live while we read)."""
|
|
166
|
+
records: list[dict] = []
|
|
167
|
+
for line in _tail_window_lines(attempt_dir / "telemetry.jsonl"):
|
|
168
|
+
line = line.strip()
|
|
169
|
+
if not line:
|
|
170
|
+
continue
|
|
171
|
+
try:
|
|
172
|
+
rec = json.loads(line)
|
|
173
|
+
except ValueError:
|
|
174
|
+
continue # torn/partial line — skip it, keep the rest
|
|
175
|
+
if isinstance(rec, dict):
|
|
176
|
+
records.append(rec)
|
|
177
|
+
return records[-limit:]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _ranks(attempt_dir: Path) -> list[dict]:
|
|
181
|
+
"""Every parseable `ranks/rank-*.json` heartbeat, sorted by rank. A torn
|
|
182
|
+
file (a rank mid-os.replace cannot tear, but a foreign writer could) is
|
|
183
|
+
skipped; a missing dir is simply 'not instrumented' — []."""
|
|
184
|
+
try:
|
|
185
|
+
paths = sorted((attempt_dir / "ranks").glob("rank-*.json"))
|
|
186
|
+
except OSError:
|
|
187
|
+
return []
|
|
188
|
+
out: list[dict] = []
|
|
189
|
+
for p in paths:
|
|
190
|
+
try:
|
|
191
|
+
rec = json.loads(p.read_text())
|
|
192
|
+
except (OSError, ValueError):
|
|
193
|
+
continue
|
|
194
|
+
if isinstance(rec, dict):
|
|
195
|
+
out.append(rec)
|
|
196
|
+
out.sort(key=lambda r: r.get("rank", 0) if isinstance(r.get("rank", 0), int) else 0)
|
|
197
|
+
return out
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _log_tail(attempt_dir: Path, tail_lines: int = _LOG_TAIL) -> str:
|
|
201
|
+
"""Last `tail_lines` lines of `launcher.log`, or "" if unreadable. Reads
|
|
202
|
+
only the bounded tail window, never the whole file."""
|
|
203
|
+
return "\n".join(_tail_window_lines(attempt_dir / "launcher.log")[-tail_lines:])
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _manifests_for_root(ckpt_root: Path) -> list[dict]:
|
|
207
|
+
"""List every `step-*/manifest.json` under one job ckpt root, each
|
|
208
|
+
re-verified against disk. Reuses `flashruntime.checkpoint.local`'s
|
|
209
|
+
hashing (`verify_manifest`) and its picker (`latest_valid_manifest`) so
|
|
210
|
+
the "is this checkpoint safe" logic is never duplicated here — the viewer
|
|
211
|
+
only *reads* checkpoints, it must agree exactly with what recovery would
|
|
212
|
+
restore. Each entry: step, re-verified validation, part count, age, and
|
|
213
|
+
whether it is the one recovery would pick (`latest_valid`)."""
|
|
214
|
+
from flashruntime.checkpoint.local import (
|
|
215
|
+
MANIFEST_NAME,
|
|
216
|
+
latest_valid_manifest,
|
|
217
|
+
verify_manifest,
|
|
218
|
+
)
|
|
219
|
+
from flashruntime.protocol.v1alpha1 import CheckpointManifest
|
|
220
|
+
|
|
221
|
+
ckpt_root = Path(ckpt_root)
|
|
222
|
+
if not ckpt_root.is_dir():
|
|
223
|
+
return []
|
|
224
|
+
latest = latest_valid_manifest(ckpt_root) # the manifest recovery would restore
|
|
225
|
+
latest_step = latest.step if latest else None
|
|
226
|
+
out: list[dict] = []
|
|
227
|
+
for mf_path in sorted(ckpt_root.glob(f"step-*/{MANIFEST_NAME}")):
|
|
228
|
+
try:
|
|
229
|
+
manifest = CheckpointManifest.model_validate_json(mf_path.read_text())
|
|
230
|
+
except (OSError, ValueError):
|
|
231
|
+
continue # torn/invalid manifest — skip, keep scanning (never crash)
|
|
232
|
+
valid = verify_manifest(manifest, mf_path.parent)
|
|
233
|
+
try:
|
|
234
|
+
age_s: float | None = time.time() - manifest.created.timestamp()
|
|
235
|
+
except Exception: # noqa: BLE001 — a weird created value must not crash the scan
|
|
236
|
+
age_s = None
|
|
237
|
+
out.append(
|
|
238
|
+
{
|
|
239
|
+
"job_id": manifest.job_id,
|
|
240
|
+
"step": manifest.step,
|
|
241
|
+
# Report the RE-VERIFIED state, not the manifest's stored claim:
|
|
242
|
+
# a part corrupted after writing makes a "hash_verified" manifest
|
|
243
|
+
# actually invalid, and the viewer must show that truth.
|
|
244
|
+
"validation": "hash_verified" if valid else "invalid",
|
|
245
|
+
"parts": len(manifest.parts),
|
|
246
|
+
"age_s": age_s,
|
|
247
|
+
"latest_valid": bool(valid and manifest.step == latest_step),
|
|
248
|
+
}
|
|
249
|
+
)
|
|
250
|
+
return out
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Workload descriptions: WHAT the user wants run (four-axes rule: this is
|
|
2
|
+
the axis-zero input the recipes/strategies/launchers axes consume)."""
|
|
3
|
+
|
|
4
|
+
from flashruntime.workloads.command import CommandWorkload, OutputSpec, Source, to_jobspec
|
|
5
|
+
|
|
6
|
+
__all__ = ["CommandWorkload", "OutputSpec", "Source", "to_jobspec"]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""User-facing description of a "bring your own code" workload.
|
|
2
|
+
|
|
3
|
+
A CommandWorkload names WHAT to run (a command in a source directory,
|
|
4
|
+
optionally in a pinned image) and what FlashRuntime should do around it
|
|
5
|
+
(inputs, outputs, isolation, Mode A fan-out). It never describes HOW
|
|
6
|
+
distributed math happens — that belongs to the user's code and its
|
|
7
|
+
framework (ADR-0003: FlashRuntime operates jobs, it does not train).
|
|
8
|
+
|
|
9
|
+
Pydantic-only: importing this module must never require torch, sklearn,
|
|
10
|
+
kubernetes, or fastapi — it is part of the clean core.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import shlex
|
|
16
|
+
from typing import Literal
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, Field, field_validator
|
|
19
|
+
|
|
20
|
+
from flashruntime.protocol.plan_v1alpha1 import CheckpointPolicy
|
|
21
|
+
from flashruntime.protocol.v1alpha1 import (
|
|
22
|
+
ExecutionSpec,
|
|
23
|
+
ImageSpec,
|
|
24
|
+
IsolationSpec,
|
|
25
|
+
JobMetadata,
|
|
26
|
+
JobSpec,
|
|
27
|
+
JobSpecInner,
|
|
28
|
+
WorkloadSpec,
|
|
29
|
+
)
|
|
30
|
+
from flashruntime.providers import Requirements
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Source(BaseModel):
|
|
34
|
+
"""Where the user's code lives. v1 executes from a local directory;
|
|
35
|
+
`git_revision` is reserved for remote packaging (spec §10 follow-up)."""
|
|
36
|
+
|
|
37
|
+
path: str = "."
|
|
38
|
+
git_revision: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class OutputSpec(BaseModel):
|
|
42
|
+
"""What to keep after a run. `collect` globs are resolved against the
|
|
43
|
+
script's working directory; `primary_metric` names the metrics.json key
|
|
44
|
+
Run.best_trial() ranks by."""
|
|
45
|
+
|
|
46
|
+
prefix: str = "artifact://jobs/{job_id}/"
|
|
47
|
+
collect: list[str] = Field(default_factory=lambda: ["metrics.json"])
|
|
48
|
+
primary_metric: str | None = None
|
|
49
|
+
maximize: bool = True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CommandWorkload(BaseModel):
|
|
53
|
+
"""One command, operated by FlashRuntime.
|
|
54
|
+
|
|
55
|
+
`command` may be a shell-style string (shlex-split, never shell=True —
|
|
56
|
+
pipes need an explicit `bash -c "..."`) or an argv list. `{name}`
|
|
57
|
+
placeholders are filled per `task_params` entry for Mode A fan-out.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
command: str | list[str]
|
|
61
|
+
source: Source = Field(default_factory=Source)
|
|
62
|
+
image: ImageSpec | None = None
|
|
63
|
+
env: dict[str, str] = Field(default_factory=dict)
|
|
64
|
+
inputs: dict[str, str] = Field(default_factory=dict)
|
|
65
|
+
outputs: OutputSpec = Field(default_factory=OutputSpec)
|
|
66
|
+
resources: Requirements = Field(default_factory=Requirements)
|
|
67
|
+
isolation: IsolationSpec = Field(default_factory=IsolationSpec)
|
|
68
|
+
mode: Literal["auto", "local", "independent_tasks", "coordinated"] = "auto"
|
|
69
|
+
checkpoint: CheckpointPolicy | None = None
|
|
70
|
+
task_params: list[dict] | None = None
|
|
71
|
+
|
|
72
|
+
@field_validator("inputs")
|
|
73
|
+
@classmethod
|
|
74
|
+
def _artifact_scheme(cls, v: dict[str, str]) -> dict[str, str]:
|
|
75
|
+
for name, uri in v.items():
|
|
76
|
+
if not str(uri).startswith("artifact://"):
|
|
77
|
+
raise ValueError(f"input '{name}' must be an artifact:// URI, got {uri!r}")
|
|
78
|
+
return v
|
|
79
|
+
|
|
80
|
+
def argv(self, params: dict | None = None) -> list[str]:
|
|
81
|
+
"""Exec-ready argv. `params` fills `{name}` placeholders; a
|
|
82
|
+
placeholder with no matching param raises KeyError (a silent empty
|
|
83
|
+
substitution would corrupt the command)."""
|
|
84
|
+
tokens = shlex.split(self.command) if isinstance(self.command, str) else list(self.command)
|
|
85
|
+
if params:
|
|
86
|
+
tokens = [t.format(**params) for t in tokens]
|
|
87
|
+
return tokens
|
|
88
|
+
|
|
89
|
+
def resolved_mode(self) -> str:
|
|
90
|
+
"""Deterministic `auto` resolution (spec §4.1): fan-out params ⇒
|
|
91
|
+
independent_tasks; a multi-process launcher command ⇒ coordinated;
|
|
92
|
+
else local. An explicit `mode` always wins."""
|
|
93
|
+
if self.mode != "auto":
|
|
94
|
+
return self.mode
|
|
95
|
+
if self.task_params:
|
|
96
|
+
return "independent_tasks"
|
|
97
|
+
tokens = self.argv()
|
|
98
|
+
if tokens and tokens[0] in ("torchrun", "accelerate"):
|
|
99
|
+
return "coordinated"
|
|
100
|
+
return "local"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def to_jobspec(workload: CommandWorkload, name: str, image: ImageSpec | None = None) -> JobSpec:
|
|
104
|
+
"""Wire form for the coordinator: JobSpec{execution.backend: leases,
|
|
105
|
+
workload.type: "command"}. A pinned image is required — remote runs
|
|
106
|
+
must be reproducible (the schema already rejects 'latest')."""
|
|
107
|
+
img = image or workload.image
|
|
108
|
+
if img is None:
|
|
109
|
+
raise ValueError("a pinned image is required to submit a command workload to the service")
|
|
110
|
+
parameters: dict = {
|
|
111
|
+
"command": workload.argv(), # normalized argv, placeholders intact
|
|
112
|
+
"env": dict(workload.env),
|
|
113
|
+
"inputs": dict(workload.inputs),
|
|
114
|
+
}
|
|
115
|
+
if workload.task_params is not None:
|
|
116
|
+
parameters["task_params"] = workload.task_params
|
|
117
|
+
if workload.checkpoint is not None:
|
|
118
|
+
parameters["checkpoint"] = workload.checkpoint.model_dump()
|
|
119
|
+
return JobSpec(
|
|
120
|
+
metadata=JobMetadata(name=name),
|
|
121
|
+
spec=JobSpecInner(
|
|
122
|
+
execution=ExecutionSpec(backend="leases"),
|
|
123
|
+
image=img,
|
|
124
|
+
workload=WorkloadSpec(type="command", parameters=parameters),
|
|
125
|
+
isolation=workload.isolation,
|
|
126
|
+
),
|
|
127
|
+
)
|