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,127 @@
|
|
|
1
|
+
"""Checkpointable logistic-regression SGD, runnable as a leased task.
|
|
2
|
+
|
|
3
|
+
Executor contract (same CLI as the other task modules):
|
|
4
|
+
|
|
5
|
+
python -m flashml_workloads.sgd_trainer --spec spec.json --out OUTDIR
|
|
6
|
+
|
|
7
|
+
`spec.json`:
|
|
8
|
+
params: steps, checkpoint_every, lr, seed,
|
|
9
|
+
kill_at_step (optional — fires only on a FRESH start, so a
|
|
10
|
+
resumed retry never re-crashes; this is the deterministic
|
|
11
|
+
kill-and-recover test hook)
|
|
12
|
+
inputs: dataset (headerless CSV, label last), resume (optional path to
|
|
13
|
+
a checkpoint json downloaded by the executor)
|
|
14
|
+
|
|
15
|
+
Checkpoints go to OUTDIR/ckpt/step-NNNNNN.json ({step, weights, bias}); the
|
|
16
|
+
executor's relay uploads them and commits manifests as they appear.
|
|
17
|
+
|
|
18
|
+
Determinism is the contract: batches are indexed by step number (cyclic
|
|
19
|
+
slices, no RNG state to carry), so resuming from step k reproduces the
|
|
20
|
+
uninterrupted run bit-for-bit — recovery must never silently change the
|
|
21
|
+
result. Pure stdlib; runs anywhere, including `--network none` containers.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import csv
|
|
28
|
+
import json
|
|
29
|
+
import math
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
BATCH = 4
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _load(path: str) -> tuple[list[list[float]], list[float]]:
|
|
37
|
+
xs, ys = [], []
|
|
38
|
+
with open(path, newline="") as f:
|
|
39
|
+
for row in csv.reader(f):
|
|
40
|
+
if row:
|
|
41
|
+
xs.append([float(v) for v in row[:-1]])
|
|
42
|
+
ys.append(float(row[-1]))
|
|
43
|
+
return xs, ys
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _loss(xs, ys, w, b) -> float:
|
|
47
|
+
total = 0.0
|
|
48
|
+
for x, y in zip(xs, ys):
|
|
49
|
+
z = sum(wi * xi for wi, xi in zip(w, x)) + b
|
|
50
|
+
p = 1.0 / (1.0 + math.exp(-z))
|
|
51
|
+
p = min(max(p, 1e-12), 1 - 1e-12)
|
|
52
|
+
total += -(y * math.log(p) + (1 - y) * math.log(1 - p))
|
|
53
|
+
return total / len(xs)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run_trainer(spec: dict, out: Path) -> dict:
|
|
57
|
+
params = spec.get("params", {})
|
|
58
|
+
steps = int(params["steps"])
|
|
59
|
+
every = int(params.get("checkpoint_every", 100))
|
|
60
|
+
lr = float(params.get("lr", 0.1))
|
|
61
|
+
kill_at = params.get("kill_at_step")
|
|
62
|
+
|
|
63
|
+
xs, ys = _load(spec["inputs"]["dataset"])
|
|
64
|
+
dims = len(xs[0])
|
|
65
|
+
|
|
66
|
+
resume_path = spec.get("inputs", {}).get("resume")
|
|
67
|
+
if resume_path:
|
|
68
|
+
ckpt = json.loads(Path(resume_path).read_text())
|
|
69
|
+
w, b, start = list(ckpt["weights"]), float(ckpt["bias"]), int(ckpt["step"])
|
|
70
|
+
resumed = True
|
|
71
|
+
else:
|
|
72
|
+
w, b, start = [0.0] * dims, 0.0, 0
|
|
73
|
+
resumed = False
|
|
74
|
+
|
|
75
|
+
out = Path(out)
|
|
76
|
+
ckpt_dir = out / "ckpt"
|
|
77
|
+
ckpt_dir.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
|
|
79
|
+
n = len(xs)
|
|
80
|
+
for step in range(start + 1, steps + 1):
|
|
81
|
+
# batch indexed by step ⇒ no RNG state; resume is trivially exact
|
|
82
|
+
base = ((step - 1) * BATCH) % n
|
|
83
|
+
idx = [(base + i) % n for i in range(BATCH)]
|
|
84
|
+
gw, gb = [0.0] * dims, 0.0
|
|
85
|
+
for i in idx:
|
|
86
|
+
z = sum(wi * xi for wi, xi in zip(w, xs[i])) + b
|
|
87
|
+
p = 1.0 / (1.0 + math.exp(-z))
|
|
88
|
+
err = p - ys[i]
|
|
89
|
+
for d in range(dims):
|
|
90
|
+
gw[d] += err * xs[i][d]
|
|
91
|
+
gb += err
|
|
92
|
+
for d in range(dims):
|
|
93
|
+
w[d] -= lr * gw[d] / BATCH
|
|
94
|
+
b -= lr * gb / BATCH
|
|
95
|
+
|
|
96
|
+
if step % every == 0 and step < steps:
|
|
97
|
+
(ckpt_dir / f"step-{step:06d}.json").write_text(
|
|
98
|
+
json.dumps({"step": step, "weights": w, "bias": b})
|
|
99
|
+
)
|
|
100
|
+
if kill_at is not None and not resumed and step >= int(kill_at):
|
|
101
|
+
# simulated crash on a fresh run only — retries resume, not re-die
|
|
102
|
+
sys.exit(3)
|
|
103
|
+
|
|
104
|
+
metrics = {
|
|
105
|
+
"task_id": spec.get("task_id", ""),
|
|
106
|
+
"steps": steps,
|
|
107
|
+
"started_from_step": start,
|
|
108
|
+
"resumed": resumed,
|
|
109
|
+
"final_loss": round(_loss(xs, ys, w, b), 12),
|
|
110
|
+
"weights": [round(v, 12) for v in w],
|
|
111
|
+
"bias": round(b, 12),
|
|
112
|
+
}
|
|
113
|
+
(out / "metrics.json").write_text(json.dumps(metrics, sort_keys=True))
|
|
114
|
+
return metrics
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def main(argv: list[str] | None = None) -> int:
|
|
118
|
+
parser = argparse.ArgumentParser(prog="sgd_trainer")
|
|
119
|
+
parser.add_argument("--spec", required=True)
|
|
120
|
+
parser.add_argument("--out", required=True)
|
|
121
|
+
args = parser.parse_args(argv)
|
|
122
|
+
run_trainer(json.loads(Path(args.spec).read_text()), Path(args.out))
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Iterative sharded K-Means on Ray tasks — the POC's real distributed job.
|
|
2
|
+
|
|
3
|
+
Shape (per docs/SYSTEM_OVERVIEW and the POC brief):
|
|
4
|
+
- deterministic synthetic dataset, generated per shard from (seed, shard_id)
|
|
5
|
+
so shards are reproducible anywhere without shipping data;
|
|
6
|
+
- each iteration maps one retriable Ray task per shard (many more shards
|
|
7
|
+
than workers → dynamic scheduling), reduces partial sums on the driver,
|
|
8
|
+
updates centroids;
|
|
9
|
+
- every task reports which Ray node / Kubernetes node / pod it ran on;
|
|
10
|
+
- retry evidence is *measured*, not fabricated: after the run the driver
|
|
11
|
+
queries Ray's task state API for tasks whose attempt count exceeds 1 and
|
|
12
|
+
correlates in-run worker-set changes;
|
|
13
|
+
- final artifacts (centroids, metrics, execution summary, node
|
|
14
|
+
contributions, recovery events) are uploaded to the configured artifact
|
|
15
|
+
store (MinIO locally, OSS on Alibaba) — durable outside any worker.
|
|
16
|
+
|
|
17
|
+
Parameters (FLASHML_WORKLOAD_PARAMS JSON): samples, dimensions, clusters,
|
|
18
|
+
shards, iterations, seed, task_delay_seconds.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import socket
|
|
27
|
+
import time
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
|
|
33
|
+
# `ray` is imported lazily inside the functions that need it: the pure data
|
|
34
|
+
# functions (make_shard, _true_centers) must stay importable in environments
|
|
35
|
+
# without Ray (unit tests, the runtime service image).
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _now_iso() -> str:
|
|
39
|
+
return datetime.now(timezone.utc).isoformat()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _params() -> dict:
|
|
43
|
+
p = json.loads(os.environ.get("FLASHML_WORKLOAD_PARAMS", "{}"))
|
|
44
|
+
return {
|
|
45
|
+
"samples": int(p.get("samples", 150_000)),
|
|
46
|
+
"dimensions": int(p.get("dimensions", 24)),
|
|
47
|
+
"clusters": int(p.get("clusters", 6)),
|
|
48
|
+
"shards": int(p.get("shards", 36)),
|
|
49
|
+
"iterations": int(p.get("iterations", 12)),
|
|
50
|
+
"seed": int(p.get("seed", 42)),
|
|
51
|
+
"task_delay_seconds": float(p.get("task_delay_seconds", 0.4)),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _true_centers(seed: int, clusters: int, dimensions: int) -> np.ndarray:
|
|
56
|
+
return np.random.default_rng(seed).uniform(-10, 10, size=(clusters, dimensions))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def make_shard(seed: int, shard_id: int, samples_per_shard: int,
|
|
60
|
+
clusters: int, dimensions: int) -> np.ndarray:
|
|
61
|
+
"""Deterministic shard: same (seed, shard_id) always yields the same
|
|
62
|
+
points, so a retried task recomputes identical data."""
|
|
63
|
+
centers = _true_centers(seed, clusters, dimensions)
|
|
64
|
+
rng = np.random.default_rng(seed * 1_000_003 + shard_id)
|
|
65
|
+
assignment = rng.integers(0, clusters, size=samples_per_shard)
|
|
66
|
+
noise = rng.normal(0.0, 1.5, size=(samples_per_shard, dimensions))
|
|
67
|
+
return centers[assignment] + noise
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def assign_shard(shard_id: int, centroids: np.ndarray, cfg: dict) -> dict:
|
|
71
|
+
"""One map task: assign a shard's points to the current centroids and
|
|
72
|
+
return partial sums/counts plus the identity of the node that did it."""
|
|
73
|
+
import ray
|
|
74
|
+
|
|
75
|
+
t0 = time.monotonic()
|
|
76
|
+
X = make_shard(cfg["seed"], shard_id, cfg["samples_per_shard"],
|
|
77
|
+
cfg["clusters"], cfg["dimensions"])
|
|
78
|
+
if cfg["task_delay_seconds"] > 0:
|
|
79
|
+
# Only to stretch the run long enough to inject a failure reliably.
|
|
80
|
+
time.sleep(cfg["task_delay_seconds"])
|
|
81
|
+
|
|
82
|
+
# Nearest-centroid assignment.
|
|
83
|
+
d2 = ((X[:, None, :] - centroids[None, :, :]) ** 2).sum(axis=2)
|
|
84
|
+
labels = d2.argmin(axis=1)
|
|
85
|
+
inertia = float(d2[np.arange(len(X)), labels].sum())
|
|
86
|
+
|
|
87
|
+
k = centroids.shape[0]
|
|
88
|
+
sums = np.zeros_like(centroids)
|
|
89
|
+
counts = np.zeros(k, dtype=np.int64)
|
|
90
|
+
for c in range(k):
|
|
91
|
+
mask = labels == c
|
|
92
|
+
counts[c] = int(mask.sum())
|
|
93
|
+
if counts[c]:
|
|
94
|
+
sums[c] = X[mask].sum(axis=0)
|
|
95
|
+
|
|
96
|
+
ctx = ray.get_runtime_context()
|
|
97
|
+
return {
|
|
98
|
+
"shard_id": shard_id,
|
|
99
|
+
"partial_sums": sums,
|
|
100
|
+
"partial_counts": counts,
|
|
101
|
+
"inertia": inertia,
|
|
102
|
+
"n_samples": int(len(X)),
|
|
103
|
+
"duration_ms": int((time.monotonic() - t0) * 1000),
|
|
104
|
+
"ray_node_id": ctx.get_node_id(),
|
|
105
|
+
"pod": os.environ.get("K8S_POD_NAME", socket.gethostname()),
|
|
106
|
+
"k8s_node": os.environ.get("K8S_NODE_NAME", "unknown"),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _worker_set() -> dict[str, str]:
|
|
111
|
+
"""Alive Ray nodes (node_id -> node name/ip), head included."""
|
|
112
|
+
import ray
|
|
113
|
+
|
|
114
|
+
return {n["NodeID"]: n.get("NodeManagerHostname", n.get("NodeManagerAddress", "?"))
|
|
115
|
+
for n in ray.nodes() if n["Alive"]}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _collect_retry_evidence(job_id: str) -> list[dict]:
|
|
119
|
+
"""Real per-task attempt metadata from Ray's state API. A task with
|
|
120
|
+
attempt_number > 0 was genuinely retried by Ray."""
|
|
121
|
+
events: list[dict] = []
|
|
122
|
+
try:
|
|
123
|
+
from ray.util.state import list_tasks
|
|
124
|
+
|
|
125
|
+
tasks = list_tasks(filters=[("name", "=", "assign_shard")],
|
|
126
|
+
limit=10_000, detail=True)
|
|
127
|
+
for t in tasks:
|
|
128
|
+
attempt = getattr(t, "attempt_number", 0) or 0
|
|
129
|
+
if attempt > 0:
|
|
130
|
+
events.append({
|
|
131
|
+
"schema_version": "v1alpha1",
|
|
132
|
+
"job_id": job_id,
|
|
133
|
+
"type": "TASK_ATTEMPT_RETRIED",
|
|
134
|
+
"timestamp": _now_iso(),
|
|
135
|
+
"source": "ray.task-state",
|
|
136
|
+
"message": f"task {t.task_id} retried (attempt {attempt + 1})",
|
|
137
|
+
"data": {
|
|
138
|
+
"task_id": t.task_id,
|
|
139
|
+
"attempt_number": attempt,
|
|
140
|
+
"state": str(getattr(t, "state", "")),
|
|
141
|
+
"node_id": getattr(t, "node_id", None),
|
|
142
|
+
"error_type": str(getattr(t, "error_type", "") or ""),
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
except Exception as exc: # state API is best-effort; never fake evidence
|
|
146
|
+
print(json.dumps({"msg": "could not query Ray task state API",
|
|
147
|
+
"error": str(exc)}))
|
|
148
|
+
return events
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def run() -> None:
|
|
152
|
+
import ray
|
|
153
|
+
|
|
154
|
+
job_id = os.environ.get("FLASHML_JOB_ID", "local-dev")
|
|
155
|
+
params = _params()
|
|
156
|
+
max_attempts = int(os.environ.get("FLASHML_MAX_TASK_ATTEMPTS", "4"))
|
|
157
|
+
|
|
158
|
+
ray.init() # inside the RayJob-managed cluster
|
|
159
|
+
|
|
160
|
+
cfg = {
|
|
161
|
+
"seed": params["seed"],
|
|
162
|
+
"clusters": params["clusters"],
|
|
163
|
+
"dimensions": params["dimensions"],
|
|
164
|
+
"samples_per_shard": max(1, params["samples"] // params["shards"]),
|
|
165
|
+
"task_delay_seconds": params["task_delay_seconds"],
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# max_retries counts *re*-executions: attempts = 1 + max_retries.
|
|
169
|
+
remote_assign = ray.remote(max_retries=max_attempts - 1, num_cpus=1)(assign_shard)
|
|
170
|
+
|
|
171
|
+
rng = np.random.default_rng(params["seed"])
|
|
172
|
+
centers = _true_centers(params["seed"], params["clusters"], params["dimensions"])
|
|
173
|
+
centroids = centers + rng.normal(0, 4.0, size=centers.shape)
|
|
174
|
+
|
|
175
|
+
print(json.dumps({"msg": "kmeans starting", "job_id": job_id, **params}))
|
|
176
|
+
initial_workers = _worker_set()
|
|
177
|
+
print(json.dumps({"msg": "ray nodes online", "nodes": list(initial_workers.values()),
|
|
178
|
+
"count": len(initial_workers)}))
|
|
179
|
+
|
|
180
|
+
metrics_history: list[dict] = []
|
|
181
|
+
recovery_events: list[dict] = []
|
|
182
|
+
node_contributions: dict[str, dict] = {}
|
|
183
|
+
seen_workers = dict(initial_workers)
|
|
184
|
+
t_start = time.monotonic()
|
|
185
|
+
|
|
186
|
+
for iteration in range(params["iterations"]):
|
|
187
|
+
it0 = time.monotonic()
|
|
188
|
+
refs = [remote_assign.remote(s, centroids, cfg) for s in range(params["shards"])]
|
|
189
|
+
results = ray.get(refs)
|
|
190
|
+
|
|
191
|
+
# Reduce.
|
|
192
|
+
sums = np.zeros_like(centroids)
|
|
193
|
+
counts = np.zeros(params["clusters"], dtype=np.int64)
|
|
194
|
+
inertia = 0.0
|
|
195
|
+
for r in results:
|
|
196
|
+
sums += r["partial_sums"]
|
|
197
|
+
counts += r["partial_counts"]
|
|
198
|
+
inertia += r["inertia"]
|
|
199
|
+
node_key = f'{r["k8s_node"]}|{r["pod"]}'
|
|
200
|
+
entry = node_contributions.setdefault(node_key, {
|
|
201
|
+
"k8s_node": r["k8s_node"], "pod": r["pod"],
|
|
202
|
+
"ray_node_id": r["ray_node_id"], "tasks": 0, "samples": 0,
|
|
203
|
+
"busy_ms": 0,
|
|
204
|
+
})
|
|
205
|
+
entry["tasks"] += 1
|
|
206
|
+
entry["samples"] += r["n_samples"]
|
|
207
|
+
entry["busy_ms"] += r["duration_ms"]
|
|
208
|
+
|
|
209
|
+
nonzero = counts > 0
|
|
210
|
+
old = centroids.copy()
|
|
211
|
+
centroids[nonzero] = sums[nonzero] / counts[nonzero, None]
|
|
212
|
+
movement = float(np.linalg.norm(centroids - old))
|
|
213
|
+
|
|
214
|
+
# Worker-set diffs are observed facts from ray.nodes().
|
|
215
|
+
current_workers = _worker_set()
|
|
216
|
+
for node_id, name in seen_workers.items():
|
|
217
|
+
if node_id not in current_workers:
|
|
218
|
+
recovery_events.append({
|
|
219
|
+
"schema_version": "v1alpha1", "job_id": job_id,
|
|
220
|
+
"type": "RAY_WORKER_LOST", "timestamp": _now_iso(),
|
|
221
|
+
"source": "ray.nodes",
|
|
222
|
+
"message": f"Ray node {name} left the cluster during iteration {iteration}",
|
|
223
|
+
"data": {"ray_node_id": node_id, "node": name,
|
|
224
|
+
"iteration": iteration},
|
|
225
|
+
})
|
|
226
|
+
for node_id, name in current_workers.items():
|
|
227
|
+
if node_id not in seen_workers:
|
|
228
|
+
recovery_events.append({
|
|
229
|
+
"schema_version": "v1alpha1", "job_id": job_id,
|
|
230
|
+
"type": "RAY_WORKER_REPLACED", "timestamp": _now_iso(),
|
|
231
|
+
"source": "ray.nodes",
|
|
232
|
+
"message": f"Ray node {name} joined during iteration {iteration}",
|
|
233
|
+
"data": {"ray_node_id": node_id, "node": name,
|
|
234
|
+
"iteration": iteration},
|
|
235
|
+
})
|
|
236
|
+
seen_workers = current_workers
|
|
237
|
+
|
|
238
|
+
entry = {
|
|
239
|
+
"iteration": iteration,
|
|
240
|
+
"inertia": inertia,
|
|
241
|
+
"movement": movement,
|
|
242
|
+
"duration_s": round(time.monotonic() - it0, 3),
|
|
243
|
+
"tasks": params["shards"],
|
|
244
|
+
"workers_online": len(current_workers),
|
|
245
|
+
"timestamp": _now_iso(),
|
|
246
|
+
}
|
|
247
|
+
metrics_history.append(entry)
|
|
248
|
+
print(json.dumps({"msg": "iteration complete", **entry}))
|
|
249
|
+
recovery_events.append({
|
|
250
|
+
"schema_version": "v1alpha1", "job_id": job_id,
|
|
251
|
+
"type": "ITERATION_COMPLETED", "timestamp": _now_iso(),
|
|
252
|
+
"source": "workload.driver",
|
|
253
|
+
"message": f"iteration {iteration} complete "
|
|
254
|
+
f"(inertia={inertia:.1f}, movement={movement:.4f})",
|
|
255
|
+
"data": entry,
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
total_s = round(time.monotonic() - t_start, 2)
|
|
259
|
+
recovery_events.extend(_collect_retry_evidence(job_id))
|
|
260
|
+
|
|
261
|
+
summary = {
|
|
262
|
+
"job_id": job_id,
|
|
263
|
+
"parameters": params,
|
|
264
|
+
"total_duration_s": total_s,
|
|
265
|
+
"iterations_run": len(metrics_history),
|
|
266
|
+
"final_inertia": metrics_history[-1]["inertia"],
|
|
267
|
+
"final_movement": metrics_history[-1]["movement"],
|
|
268
|
+
"tasks_total": params["shards"] * params["iterations"],
|
|
269
|
+
"max_task_attempts": max_attempts,
|
|
270
|
+
"retried_tasks": sum(1 for e in recovery_events
|
|
271
|
+
if e["type"] == "TASK_ATTEMPT_RETRIED"),
|
|
272
|
+
"workers_lost": sum(1 for e in recovery_events
|
|
273
|
+
if e["type"] == "RAY_WORKER_LOST"),
|
|
274
|
+
"nodes_contributing": len(node_contributions),
|
|
275
|
+
"finished_at": _now_iso(),
|
|
276
|
+
}
|
|
277
|
+
print(json.dumps({"msg": "kmeans finished", **summary}))
|
|
278
|
+
|
|
279
|
+
_upload_artifacts(job_id, {
|
|
280
|
+
"centroids.json": {"centroids": centroids.tolist(),
|
|
281
|
+
"clusters": params["clusters"],
|
|
282
|
+
"dimensions": params["dimensions"]},
|
|
283
|
+
"metrics.json": {"history": metrics_history},
|
|
284
|
+
"execution-summary.json": summary,
|
|
285
|
+
"node-contributions.json": {"nodes": list(node_contributions.values())},
|
|
286
|
+
"recovery-events.json": recovery_events,
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _upload_artifacts(job_id: str, artifacts: dict[str, object]) -> None:
|
|
291
|
+
prefix = os.environ.get("FLASHML_ARTIFACT_PREFIX", f"artifact://jobs/{job_id}/")
|
|
292
|
+
try:
|
|
293
|
+
from flashruntime.artifacts import artifact_uri_to_key, store_from_env
|
|
294
|
+
|
|
295
|
+
store = store_from_env()
|
|
296
|
+
except Exception as exc:
|
|
297
|
+
print(json.dumps({"msg": "artifact store unavailable; writing locally",
|
|
298
|
+
"error": str(exc)}))
|
|
299
|
+
out = Path("/tmp/flashml-artifacts") / job_id
|
|
300
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
301
|
+
for name, payload in artifacts.items():
|
|
302
|
+
(out / name).write_text(json.dumps(payload, indent=2))
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
key_prefix = artifact_uri_to_key(prefix)
|
|
306
|
+
|
|
307
|
+
async def _put_all():
|
|
308
|
+
import tempfile
|
|
309
|
+
|
|
310
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
311
|
+
for name, payload in artifacts.items():
|
|
312
|
+
path = Path(tmp) / name
|
|
313
|
+
path.write_text(json.dumps(payload, indent=2))
|
|
314
|
+
record = await store.put_file(path, f"{key_prefix}{name}")
|
|
315
|
+
print(json.dumps({"msg": "artifact committed", "uri": record.uri,
|
|
316
|
+
"etag": record.etag,
|
|
317
|
+
"size_bytes": record.size_bytes}))
|
|
318
|
+
|
|
319
|
+
asyncio.run(_put_all())
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
if __name__ == "__main__":
|
|
323
|
+
run()
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""One hyperparameter-search trial, runnable as a leased task.
|
|
2
|
+
|
|
3
|
+
Executor contract (Tier-1 subprocess runner):
|
|
4
|
+
|
|
5
|
+
python -m flashml_workloads.sklearn_trial --spec spec.json --out OUTDIR
|
|
6
|
+
|
|
7
|
+
`spec.json` (written by the executor from the lease payload):
|
|
8
|
+
{
|
|
9
|
+
"task_id": "trial-003",
|
|
10
|
+
"params": {"model": "logreg", "C": 0.1}, # this trial's config
|
|
11
|
+
"inputs": {"dataset": "/abs/path/to/dataset.csv"} # downloaded shared data
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
The dataset is a headerless CSV, features then label in the last column —
|
|
15
|
+
shared data hosted by the coordinator's local artifact endpoint, downloaded
|
|
16
|
+
once per task. Output: `OUTDIR/metrics.json` with the cross-validated
|
|
17
|
+
accuracy; the executor uploads it and commits its sha256.
|
|
18
|
+
|
|
19
|
+
Supported params: model ∈ {logreg, rf}; logreg: C; rf: n_estimators,
|
|
20
|
+
max_depth. Deterministic (fixed random_state) so retried attempts produce
|
|
21
|
+
identical results — which is what makes idempotent commit meaningful.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import csv
|
|
28
|
+
import json
|
|
29
|
+
import time
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run_trial(spec: dict) -> dict:
|
|
34
|
+
from sklearn.ensemble import RandomForestClassifier
|
|
35
|
+
from sklearn.linear_model import LogisticRegression
|
|
36
|
+
from sklearn.model_selection import cross_val_score
|
|
37
|
+
|
|
38
|
+
params = spec.get("params", {})
|
|
39
|
+
dataset_path = spec["inputs"]["dataset"]
|
|
40
|
+
|
|
41
|
+
features: list[list[float]] = []
|
|
42
|
+
labels: list[float] = []
|
|
43
|
+
with open(dataset_path, newline="") as f:
|
|
44
|
+
for row in csv.reader(f):
|
|
45
|
+
if not row:
|
|
46
|
+
continue
|
|
47
|
+
features.append([float(x) for x in row[:-1]])
|
|
48
|
+
labels.append(float(row[-1]))
|
|
49
|
+
|
|
50
|
+
model_kind = params.get("model", "logreg")
|
|
51
|
+
if model_kind == "logreg":
|
|
52
|
+
model = LogisticRegression(C=float(params.get("C", 1.0)), max_iter=500, random_state=0)
|
|
53
|
+
elif model_kind == "rf":
|
|
54
|
+
model = RandomForestClassifier(
|
|
55
|
+
n_estimators=int(params.get("n_estimators", 50)),
|
|
56
|
+
max_depth=int(params["max_depth"]) if params.get("max_depth") else None,
|
|
57
|
+
random_state=0,
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
raise ValueError(f"unsupported model {model_kind!r} (logreg|rf)")
|
|
61
|
+
|
|
62
|
+
start = time.monotonic()
|
|
63
|
+
scores = cross_val_score(model, features, labels, cv=3)
|
|
64
|
+
return {
|
|
65
|
+
"task_id": spec.get("task_id", ""),
|
|
66
|
+
"params": params,
|
|
67
|
+
"accuracy_mean": round(float(sum(scores) / len(scores)), 4),
|
|
68
|
+
"accuracy_folds": [round(float(s), 4) for s in scores],
|
|
69
|
+
"n_samples": len(labels),
|
|
70
|
+
"train_seconds": round(time.monotonic() - start, 3),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main(argv: list[str] | None = None) -> int:
|
|
75
|
+
parser = argparse.ArgumentParser(prog="sklearn_trial")
|
|
76
|
+
parser.add_argument("--spec", required=True, help="path to spec.json")
|
|
77
|
+
parser.add_argument("--out", required=True, help="output directory")
|
|
78
|
+
args = parser.parse_args(argv)
|
|
79
|
+
|
|
80
|
+
spec = json.loads(Path(args.spec).read_text())
|
|
81
|
+
metrics = run_trial(spec)
|
|
82
|
+
out = Path(args.out)
|
|
83
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
(out / "metrics.json").write_text(json.dumps(metrics, indent=2, sort_keys=True))
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
raise SystemExit(main())
|
flashruntime/__init__.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""FlashRuntime — open fault-tolerant distributed ML runtime.
|
|
2
|
+
|
|
3
|
+
A clean, pure-Python core (`pip install flashruntime` brings only pydantic):
|
|
4
|
+
|
|
5
|
+
1. **Strategy planner** — decide *how* a job should run, with the math shown:
|
|
6
|
+
|
|
7
|
+
import flashruntime as flash
|
|
8
|
+
|
|
9
|
+
report = flash.plan(flash.PlanRequest(
|
|
10
|
+
workload=flash.TransformerFineTune(model="Qwen/Qwen2.5-7B", method="lora"),
|
|
11
|
+
resources=flash.Resources(gpus=4, gpu_type="RTX4090"),
|
|
12
|
+
objective=flash.Objective(mode="balanced", deadline_minutes=240),
|
|
13
|
+
))
|
|
14
|
+
print(flash.render(report))
|
|
15
|
+
|
|
16
|
+
2. **Lease manager** (`flashruntime.leases`) — the Mode A reliability core:
|
|
17
|
+
claim → heartbeat → expiry → requeue → idempotent commit, as a plain
|
|
18
|
+
state machine anyone can embed (the FlashML coordinator is one consumer).
|
|
19
|
+
|
|
20
|
+
3. **Checkpoint catalog** (`flashruntime.checkpoint`) — manifests with
|
|
21
|
+
parts-first / manifest-last commit: a partial checkpoint can never look
|
|
22
|
+
valid, and recovery selects only verified, topology-compatible state.
|
|
23
|
+
|
|
24
|
+
4. **Recovery policy** (`flashruntime.recovery`) — the typed failure
|
|
25
|
+
taxonomy and a versioned, deterministic action table. No agents, no
|
|
26
|
+
guesswork: same failure + same policy version ⇒ same action.
|
|
27
|
+
|
|
28
|
+
Infrastructure integrations are opt-in extras, never core imports:
|
|
29
|
+
`[service]` FastAPI coordinator + CLI job commands · `[k8s]` KubeRay
|
|
30
|
+
backend · `[artifacts]`/`[oss]` MinIO / Alibaba OSS stores ·
|
|
31
|
+
`[sklearn]` numpy + scikit-learn for the built-in task-module examples
|
|
32
|
+
(`flashml_workloads/`).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from flashruntime.planner import PLANNER_VERSION, plan, render
|
|
38
|
+
from flashruntime.protocol.plan_v1alpha1 import (
|
|
39
|
+
ClassicalML,
|
|
40
|
+
IndependentTasks,
|
|
41
|
+
Objective,
|
|
42
|
+
PlanReport,
|
|
43
|
+
PlanRequest,
|
|
44
|
+
PyTorchTraining,
|
|
45
|
+
Resources,
|
|
46
|
+
StrategyPlan,
|
|
47
|
+
TransformerFineTune,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
# planner
|
|
52
|
+
"plan",
|
|
53
|
+
"render",
|
|
54
|
+
"run",
|
|
55
|
+
"PLANNER_VERSION",
|
|
56
|
+
"PlanRequest",
|
|
57
|
+
"PlanReport",
|
|
58
|
+
"StrategyPlan",
|
|
59
|
+
"TransformerFineTune",
|
|
60
|
+
"PyTorchTraining",
|
|
61
|
+
"ClassicalML",
|
|
62
|
+
"IndependentTasks",
|
|
63
|
+
"Resources",
|
|
64
|
+
"Objective",
|
|
65
|
+
# bring-your-own-code SDK (lazy — stdlib+pydantic only, but kept lazy
|
|
66
|
+
# so `import flashruntime` stays minimal)
|
|
67
|
+
"submit",
|
|
68
|
+
"CommandWorkload",
|
|
69
|
+
"OutputSpec",
|
|
70
|
+
"Source",
|
|
71
|
+
"integrations",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
def run(plan: StrategyPlan, coordinator_url: str | None = None):
|
|
75
|
+
"""Execute a planner-selected StrategyPlan — **designed, not yet built**.
|
|
76
|
+
|
|
77
|
+
This is the plan→execution bridge (HANDBOOK §6 "known conscious
|
|
78
|
+
debts"). The designed pipeline, so the implementer starts from the
|
|
79
|
+
intended shape rather than inventing one:
|
|
80
|
+
|
|
81
|
+
1. Freeze & record: the plan's `plan_id` is stamped on the job attempt
|
|
82
|
+
(every incident must answer "what did we think, and why").
|
|
83
|
+
2. Translate: for `workload_mode == "independent_tasks"`, build a
|
|
84
|
+
lease-backend JobSpec via the matching `recipes.WorkloadRecipe` and
|
|
85
|
+
POST it to the coordinator (`coordinator_url` or
|
|
86
|
+
FLASHML_RUNTIME_API). For `coordinated_training`,
|
|
87
|
+
`strategies.compiler_for(plan).compile(plan)` →
|
|
88
|
+
`launchers` by `plan.launcher` → launch and watch.
|
|
89
|
+
3. Return a run handle exposing job_id, state polling, and the ledger
|
|
90
|
+
event stream — mirroring `LaunchHandle` semantics.
|
|
91
|
+
|
|
92
|
+
Until then this raises NotImplementedError so callers fail loudly at
|
|
93
|
+
the boundary instead of half-running: today you plan with
|
|
94
|
+
`flash.plan()` and submit the JobSpec yourself (see
|
|
95
|
+
e2e/test_local_loop.py for the manual pattern).
|
|
96
|
+
"""
|
|
97
|
+
raise NotImplementedError(
|
|
98
|
+
"flash.run() is designed but not implemented — build the plan→JobSpec "
|
|
99
|
+
"translation via recipes/ (Mode A) or strategies/+launchers/ (Mode B); "
|
|
100
|
+
"see the docstring for the intended pipeline and e2e/test_local_loop.py "
|
|
101
|
+
"for today's manual equivalent."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# SDK exports resolve lazily: name -> (module, attribute) so the pydantic-only
|
|
106
|
+
# core stays minimal until a bring-your-own-code helper is actually used.
|
|
107
|
+
_SDK_EXPORTS = {
|
|
108
|
+
"submit": ("flashruntime.sdk", "submit"),
|
|
109
|
+
"CommandWorkload": ("flashruntime.workloads.command", "CommandWorkload"),
|
|
110
|
+
"OutputSpec": ("flashruntime.workloads.command", "OutputSpec"),
|
|
111
|
+
"Source": ("flashruntime.workloads.command", "Source"),
|
|
112
|
+
"integrations": ("flashruntime.integrations", None),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def __getattr__(name: str) -> Any:
|
|
117
|
+
if name in _SDK_EXPORTS:
|
|
118
|
+
import importlib
|
|
119
|
+
|
|
120
|
+
module_name, attr = _SDK_EXPORTS[name]
|
|
121
|
+
module = importlib.import_module(module_name)
|
|
122
|
+
value = module if attr is None else getattr(module, attr)
|
|
123
|
+
globals()[name] = value
|
|
124
|
+
return value
|
|
125
|
+
raise AttributeError(f"module 'flashruntime' has no attribute {name!r}")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Backend-neutral artifact storage.
|
|
2
|
+
|
|
3
|
+
Public artifact identity is always an `artifact://` URI; the physical
|
|
4
|
+
location (MinIO bucket locally, OSS bucket on Alibaba) is an ArtifactRecord
|
|
5
|
+
detail. Implementations: S3CompatibleArtifactStore (MinIO and S3-compatible
|
|
6
|
+
endpoints) and OSSArtifactStore (native Alibaba OSS via oss2).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from flashruntime.artifacts.store import (
|
|
10
|
+
ArtifactStore,
|
|
11
|
+
OSSArtifactStore,
|
|
12
|
+
S3CompatibleArtifactStore,
|
|
13
|
+
artifact_uri_to_key,
|
|
14
|
+
key_to_artifact_uri,
|
|
15
|
+
store_from_env,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ArtifactStore",
|
|
20
|
+
"OSSArtifactStore",
|
|
21
|
+
"S3CompatibleArtifactStore",
|
|
22
|
+
"artifact_uri_to_key",
|
|
23
|
+
"key_to_artifact_uri",
|
|
24
|
+
"store_from_env",
|
|
25
|
+
]
|