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,7 @@
|
|
|
1
|
+
"""First-party FlashML example workloads, baked into the workload image.
|
|
2
|
+
|
|
3
|
+
Entrypoint convention (relied on by the KubeRay backend):
|
|
4
|
+
`python -m flashml_workloads.<workload type>` with parameters in the
|
|
5
|
+
FLASHML_WORKLOAD_PARAMS env var (JSON) and job identity/artifact wiring in
|
|
6
|
+
the other FLASHML_* env vars.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
"""Federated averaging as a sequence of lease jobs.
|
|
2
|
+
|
|
3
|
+
One round = one Mode A job (N independent shard tasks); the driver reduces
|
|
4
|
+
the shard deltas into new weights and submits the next round. Same
|
|
5
|
+
stage-composition pattern as `kmeans_driver` — "pipelines are jobs chained
|
|
6
|
+
by a driver, not a new execution mode" — so a dead worker costs one shard
|
|
7
|
+
retry and a dead driver resumes from the last completed round.
|
|
8
|
+
|
|
9
|
+
The one deliberate difference from kmeans_driver: it required *every*
|
|
10
|
+
shard (`if len(partials) != len(shard_uris): raise`). This driver
|
|
11
|
+
aggregates on a QUORUM. Volunteer machines are unequal and unreliable by
|
|
12
|
+
definition; requiring all of them would let one closed laptop stall every
|
|
13
|
+
participant's round. Deltas arriving after aggregation are DISCARDED, never
|
|
14
|
+
carried into a later round — they were computed against weights that no
|
|
15
|
+
longer exist, and applying them would silently corrupt the average.
|
|
16
|
+
|
|
17
|
+
Pure stdlib: this runs inside the cloud API, which must not carry torch.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
import time
|
|
25
|
+
import urllib.error
|
|
26
|
+
import urllib.parse
|
|
27
|
+
import urllib.request
|
|
28
|
+
from typing import Any, Callable, Protocol, Sequence, TypedDict
|
|
29
|
+
|
|
30
|
+
from flashml_workloads.fedavg_weights import (
|
|
31
|
+
apply_delta,
|
|
32
|
+
reduce_deltas,
|
|
33
|
+
require_finite,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_SAFE_DELTA_FILE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
37
|
+
|
|
38
|
+
#: Default container image for a round's tasks. Overridable per run — see
|
|
39
|
+
#: `run_fedavg(image=...)`.
|
|
40
|
+
DEFAULT_IMAGE = "local/tier1:dev"
|
|
41
|
+
|
|
42
|
+
__all__ = ["ArtifactNotFound", "BuildRound", "Coordinator",
|
|
43
|
+
"CoordinatorUnavailable", "DEFAULT_IMAGE", "HttpCoordinator",
|
|
44
|
+
"QuorumNotMet", "RoundPlan", "RoundResult", "resume_state",
|
|
45
|
+
"run_fedavg"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class QuorumNotMet(RuntimeError):
|
|
49
|
+
"""A round's deadline passed with too few committed shards."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CoordinatorUnavailable(RuntimeError):
|
|
53
|
+
"""A poll-loop coordinator call kept failing after bounded retries.
|
|
54
|
+
|
|
55
|
+
Distinct from `QuorumNotMet`: the round did not run out of participants,
|
|
56
|
+
the driver ran out of coordinator. Bounded on purpose — retrying forever
|
|
57
|
+
would turn an outage into a run that never ends and never reports.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ArtifactNotFound(LookupError):
|
|
62
|
+
"""No artifact exists at that key.
|
|
63
|
+
|
|
64
|
+
A named exception, not a bare `Exception` catch: `resume_state` must
|
|
65
|
+
distinguish "this round never completed" (expected, keep looking) from
|
|
66
|
+
"the coordinator is unreachable" (fatal, must not look like round 0).
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class RoundResult(TypedDict):
|
|
71
|
+
round: int
|
|
72
|
+
participants: int
|
|
73
|
+
mean_loss: float
|
|
74
|
+
job_id: str
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class RoundPlan(TypedDict):
|
|
78
|
+
"""What one round is: the job body to submit, and the task ids it will
|
|
79
|
+
produce.
|
|
80
|
+
|
|
81
|
+
``task_ids`` is carried rather than derived because the two things the
|
|
82
|
+
driver needs from a round — "submit this" and "look for these commits" —
|
|
83
|
+
are decided by whoever built the body. The built-in body expands via
|
|
84
|
+
``service/modea._expand_fedavg`` (``shard-000``, ``shard-001``, …); a
|
|
85
|
+
caller compiling the round as a ``command`` workload gets
|
|
86
|
+
``CommandRecipe``'s ``task-000``, ``task-001``, … instead. Inferring the
|
|
87
|
+
prefix from the workload type would be a second place that has to know
|
|
88
|
+
every recipe's naming rule, and the artifact key filter is a security
|
|
89
|
+
boundary here (see ``_committed_metrics_keys``) — so it is stated, not
|
|
90
|
+
guessed.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
body: dict
|
|
94
|
+
task_ids: list[str]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
#: Build the ``RoundPlan`` for round ``r`` given the round's weights URI
|
|
98
|
+
#: (``None`` on round 0, when nothing has been aggregated yet).
|
|
99
|
+
BuildRound = Callable[[int, "str | None"], RoundPlan]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Coordinator(Protocol):
|
|
103
|
+
"""The coordinator operations the driver needs.
|
|
104
|
+
|
|
105
|
+
Declared as a Protocol so tests substitute a fake without HTTP, and so
|
|
106
|
+
the cloud API can pass an implementation that adds auth headers.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def submit(self, body: dict) -> dict: ...
|
|
110
|
+
def job_state(self, job_id: str) -> str: ...
|
|
111
|
+
def artifacts(self, job_id: str) -> list[dict]: ...
|
|
112
|
+
def get_artifact(self, key: str) -> Any: ...
|
|
113
|
+
def put_artifact(self, key: str, body: Any) -> None: ...
|
|
114
|
+
|
|
115
|
+
# Optional. When present it is the authoritative participant count:
|
|
116
|
+
# artifacts prove only that *something was uploaded*, tasks prove that
|
|
117
|
+
# the coordinator ACCEPTED a commit. A Coordinator without it still
|
|
118
|
+
# works (the expected-key filter alone bounds the count at num_shards),
|
|
119
|
+
# so it is probed with getattr rather than being a hard requirement.
|
|
120
|
+
def tasks(self, job_id: str) -> list[dict]: ...
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _round_body(round_idx: int, num_shards: int, worker_params: dict,
|
|
124
|
+
weights_uri: str | None, lease_seconds: float,
|
|
125
|
+
image: str, isolation_tier: str, allow_fallback: bool) -> dict:
|
|
126
|
+
params: dict[str, Any] = dict(worker_params)
|
|
127
|
+
params.update({"round": round_idx, "num_shards": num_shards,
|
|
128
|
+
"lease_seconds": lease_seconds})
|
|
129
|
+
if weights_uri is not None:
|
|
130
|
+
params["weights"] = weights_uri
|
|
131
|
+
repository, _, tag = image.rpartition(":")
|
|
132
|
+
if not repository or not tag:
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"image must be 'repository:tag' with a pinned tag, got {image!r}"
|
|
135
|
+
)
|
|
136
|
+
return {
|
|
137
|
+
"apiVersion": "flashml.dev/v1alpha1", "kind": "Job",
|
|
138
|
+
"metadata": {"name": f"fedavg-r{round_idx:03d}"},
|
|
139
|
+
"spec": {
|
|
140
|
+
"execution": {"backend": "leases"},
|
|
141
|
+
"image": {"repository": repository, "tag": tag},
|
|
142
|
+
"isolation": {"tier": isolation_tier, "allowFallback": allow_fallback},
|
|
143
|
+
"workload": {"type": "federated_averaging", "parameters": params},
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _default_task_ids(num_shards: int) -> list[str]:
|
|
149
|
+
"""Task ids `service/modea._expand_fedavg` produces for a round."""
|
|
150
|
+
return [f"shard-{i:03d}" for i in range(num_shards)]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _expected_metrics_keys(job_id: str, task_ids: Sequence[str]) -> dict[str, str]:
|
|
154
|
+
"""`{artifact key: task_id}` for exactly the tasks this round dispatched.
|
|
155
|
+
|
|
156
|
+
Every expansion anchors a task's commit at
|
|
157
|
+
`jobs/{job_id}/{task_id}/metrics.json`; which task ids exist is the
|
|
158
|
+
round's `RoundPlan.task_ids`.
|
|
159
|
+
"""
|
|
160
|
+
return {f"jobs/{job_id}/{task_id}/metrics.json": task_id
|
|
161
|
+
for task_id in task_ids}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _committed_metrics_keys(coord: Coordinator, job_id: str,
|
|
165
|
+
task_ids: Sequence[str]) -> list[str]:
|
|
166
|
+
"""Keys of the round's committed metrics.json artifacts.
|
|
167
|
+
|
|
168
|
+
Two filters, because a participant count is a security boundary here —
|
|
169
|
+
it decides how much weight one machine gets in the average, and a
|
|
170
|
+
volunteer that mints extra "participants" both dilutes everyone else
|
|
171
|
+
and inflates its own share.
|
|
172
|
+
|
|
173
|
+
1. An EXACT match against the round's expected task set, never a
|
|
174
|
+
`endswith("metrics.json")` suffix test. The agent uploads a task's
|
|
175
|
+
whole output tree recursively, so a worker that writes
|
|
176
|
+
`out/a/metrics.json` and `out/b/metrics.json` would otherwise mint
|
|
177
|
+
two extra participants out of a single lease — and a key naming a
|
|
178
|
+
task id outside the round's own set would mint one out of nothing.
|
|
179
|
+
2. Cross-checked against the coordinator's task states when the
|
|
180
|
+
Coordinator exposes them. Artifact PUTs happen BEFORE the commit is
|
|
181
|
+
offered, so an attempt the coordinator went on to REJECT (lost
|
|
182
|
+
lease, sha256 mismatch, attempts exhausted) still leaves its
|
|
183
|
+
metrics.json sitting in the bucket. Only a task the coordinator
|
|
184
|
+
reports COMPLETED had its commit accepted.
|
|
185
|
+
|
|
186
|
+
Cheap: one or two listing calls. Kept separate from `_fetch` so the
|
|
187
|
+
quorum poll does not re-download every delta on every tick — deltas are
|
|
188
|
+
megabytes, and polling re-fetching them would dominate the round's
|
|
189
|
+
transfer cost.
|
|
190
|
+
"""
|
|
191
|
+
expected = _expected_metrics_keys(job_id, task_ids)
|
|
192
|
+
present = {a["key"] for a in coord.artifacts(job_id)} & expected.keys()
|
|
193
|
+
|
|
194
|
+
list_tasks = getattr(coord, "tasks", None)
|
|
195
|
+
if list_tasks is not None:
|
|
196
|
+
completed = {t["task_id"] for t in list_tasks(job_id)
|
|
197
|
+
if t.get("state") == "COMPLETED"}
|
|
198
|
+
present = {k for k in present if expected[k] in completed}
|
|
199
|
+
return sorted(present)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _safe_delta_key(metrics_key: str, delta_file: str) -> str:
|
|
203
|
+
"""Resolve a task's declared delta filename inside its own output prefix.
|
|
204
|
+
|
|
205
|
+
`delta_file` comes from metrics.json, which is written by an UNTRUSTED
|
|
206
|
+
volunteer node. Without this check a malicious node could name
|
|
207
|
+
`../../other-job/weights.json` and make the driver read — and average
|
|
208
|
+
in — an artifact belonging to somebody else's job. Result verification
|
|
209
|
+
is M3; this is not that, it is basic path containment and belongs here.
|
|
210
|
+
|
|
211
|
+
This is an ALLOWLIST, not a denylist: only a plain filename made of
|
|
212
|
+
ASCII letters/digits/`._-`, not starting with `.`, passes. A denylist of
|
|
213
|
+
specific bad substrings (`/`, `\\`, `..`) would still let a URL-encoded
|
|
214
|
+
`%2F`, a leading `~`, or an embedded NUL through — this function's
|
|
215
|
+
docstring claims it *is* the containment layer, so it must not be a
|
|
216
|
+
partial list of things we happened to think of.
|
|
217
|
+
"""
|
|
218
|
+
if delta_file in (".", "..") or not _SAFE_DELTA_FILE.match(delta_file):
|
|
219
|
+
raise ValueError(
|
|
220
|
+
f"task declared an unsafe delta_file {delta_file!r}: "
|
|
221
|
+
"must be a plain filename in the task's own output prefix"
|
|
222
|
+
)
|
|
223
|
+
return metrics_key.rsplit("/", 1)[0] + "/" + delta_file
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _fetch(coord: Coordinator, metrics_keys: list[str]) -> list[tuple[dict, int, float]]:
|
|
227
|
+
"""Download (delta, samples, loss) for the keys that met quorum."""
|
|
228
|
+
out = []
|
|
229
|
+
for key in metrics_keys:
|
|
230
|
+
metrics = coord.get_artifact(key)
|
|
231
|
+
delta_key = _safe_delta_key(key, metrics.get("delta_file", "delta.json"))
|
|
232
|
+
out.append((coord.get_artifact(delta_key),
|
|
233
|
+
int(metrics["samples"]), float(metrics["loss"])))
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class HttpCoordinator:
|
|
238
|
+
"""`Coordinator` over the coordinator's HTTP API.
|
|
239
|
+
|
|
240
|
+
`headers` carries the caller's credentials — the cloud API passes the
|
|
241
|
+
machine/service token here rather than the driver knowing anything
|
|
242
|
+
about auth.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
def __init__(self, base_url: str, headers: dict[str, str] | None = None):
|
|
246
|
+
self.base_url = base_url.rstrip("/")
|
|
247
|
+
self.headers = dict(headers or {})
|
|
248
|
+
|
|
249
|
+
def _request(self, method: str, url: str, data: bytes | None = None,
|
|
250
|
+
headers: dict | None = None, timeout: float | None = 60.0):
|
|
251
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
252
|
+
for k, v in (headers or {}).items():
|
|
253
|
+
req.add_header(k, v)
|
|
254
|
+
if data is not None:
|
|
255
|
+
req.add_header("Content-Type", "application/json")
|
|
256
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
257
|
+
raw = resp.read()
|
|
258
|
+
return json.loads(raw) if raw else None
|
|
259
|
+
|
|
260
|
+
def submit(self, body: dict) -> dict:
|
|
261
|
+
return self._request("POST", f"{self.base_url}/v1alpha1/jobs",
|
|
262
|
+
data=json.dumps(body).encode(), headers=self.headers)
|
|
263
|
+
|
|
264
|
+
def job_state(self, job_id: str) -> str:
|
|
265
|
+
job_id_q = urllib.parse.quote(job_id, safe="/")
|
|
266
|
+
return self._request("GET", f"{self.base_url}/v1alpha1/jobs/{job_id_q}",
|
|
267
|
+
headers=self.headers)["state"]
|
|
268
|
+
|
|
269
|
+
def artifacts(self, job_id: str) -> list[dict]:
|
|
270
|
+
job_id_q = urllib.parse.quote(job_id, safe="/")
|
|
271
|
+
return self._request("GET", f"{self.base_url}/v1alpha1/jobs/{job_id_q}/artifacts",
|
|
272
|
+
headers=self.headers)
|
|
273
|
+
|
|
274
|
+
def tasks(self, job_id: str) -> list[dict]:
|
|
275
|
+
"""`[{"task_id": ..., "state": "COMPLETED"|...}, ...]` for the job.
|
|
276
|
+
|
|
277
|
+
The coordinator's task view (`GET /v1alpha1/jobs/{id}/tasks`) is the
|
|
278
|
+
only place that knows whether a commit was ACCEPTED; the artifact
|
|
279
|
+
listing only knows something was uploaded.
|
|
280
|
+
"""
|
|
281
|
+
job_id_q = urllib.parse.quote(job_id, safe="/")
|
|
282
|
+
return self._request("GET", f"{self.base_url}/v1alpha1/jobs/{job_id_q}/tasks",
|
|
283
|
+
headers=self.headers)
|
|
284
|
+
|
|
285
|
+
def get_artifact(self, key: str):
|
|
286
|
+
key_q = urllib.parse.quote(key, safe="/")
|
|
287
|
+
try:
|
|
288
|
+
return self._request("GET", f"{self.base_url}/v1alpha1/artifacts/{key_q}",
|
|
289
|
+
headers=self.headers)
|
|
290
|
+
except urllib.error.HTTPError as exc:
|
|
291
|
+
if exc.code == 404:
|
|
292
|
+
raise ArtifactNotFound(key) from None
|
|
293
|
+
raise # 5xx / auth failures are NOT "round never completed"
|
|
294
|
+
|
|
295
|
+
def put_artifact(self, key: str, body) -> None:
|
|
296
|
+
key_q = urllib.parse.quote(key, safe="/")
|
|
297
|
+
self._request("PUT", f"{self.base_url}/v1alpha1/artifacts/{key_q}",
|
|
298
|
+
data=json.dumps(body).encode(), headers=self.headers)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def resume_state(coord: Coordinator,
|
|
302
|
+
job_ids: Sequence[tuple[int, str]]) -> tuple[int, dict, str | None]:
|
|
303
|
+
"""Where to restart after a driver crash.
|
|
304
|
+
|
|
305
|
+
`job_ids` is a sequence of `(round, job_id)` pairs — the round number is
|
|
306
|
+
CARRIED, never inferred from the list position. Position-as-round is
|
|
307
|
+
only true for a run that started at round 0: after resuming at round 5
|
|
308
|
+
the list holds round 5 at index 0, so a second crash would probe
|
|
309
|
+
`round-000` under the round-5 job, get `ArtifactNotFound`, and silently
|
|
310
|
+
restart training from scratch. The tuple removes the ambiguity rather
|
|
311
|
+
than documenting it.
|
|
312
|
+
|
|
313
|
+
Rounds are idempotent: the weights artifact is written only AFTER a
|
|
314
|
+
round aggregates, so the newest one that exists names the last round
|
|
315
|
+
that fully completed.
|
|
316
|
+
|
|
317
|
+
Only ArtifactNotFound is swallowed. A transport error must propagate:
|
|
318
|
+
silently treating an unreachable coordinator as "no rounds done" would
|
|
319
|
+
restart a finished run from scratch.
|
|
320
|
+
|
|
321
|
+
An artifact that EXISTS (no ArtifactNotFound) but is falsy — `{}`, or
|
|
322
|
+
`None` from an empty-body 200 — is a different situation from "this
|
|
323
|
+
round never completed": something committed a weights key with no
|
|
324
|
+
usable content. Treating that identically to "keep searching" would
|
|
325
|
+
silently walk past a corrupt commit and redo already-completed work
|
|
326
|
+
(or worse, resume from stale weights further back). Surface it instead
|
|
327
|
+
of guessing.
|
|
328
|
+
"""
|
|
329
|
+
pairs: list[tuple[int, str]] = []
|
|
330
|
+
for entry in job_ids:
|
|
331
|
+
if isinstance(entry, str) or len(entry) != 2:
|
|
332
|
+
raise TypeError(
|
|
333
|
+
"resume_state expects (round, job_id) pairs, got "
|
|
334
|
+
f"{entry!r}; a bare job-id list re-introduces the "
|
|
335
|
+
"position-is-the-round bug that breaks on a second resume"
|
|
336
|
+
)
|
|
337
|
+
pairs.append((int(entry[0]), str(entry[1])))
|
|
338
|
+
|
|
339
|
+
for r, job_id in sorted(pairs, reverse=True):
|
|
340
|
+
key = f"jobs/{job_id}/round-{r:03d}/weights.json"
|
|
341
|
+
try:
|
|
342
|
+
weights = coord.get_artifact(key)
|
|
343
|
+
except ArtifactNotFound:
|
|
344
|
+
continue
|
|
345
|
+
if not weights:
|
|
346
|
+
raise RuntimeError(
|
|
347
|
+
f"weights artifact at {key!r} exists but is empty; "
|
|
348
|
+
"cannot distinguish a corrupt commit from a round that "
|
|
349
|
+
"never completed"
|
|
350
|
+
)
|
|
351
|
+
# This is the READ path: the weights artifact was written by a PUT
|
|
352
|
+
# that is currently unauthenticated, so a corrupted or
|
|
353
|
+
# attacker-written weights.json must not be handed back to the
|
|
354
|
+
# caller un-gated. Without this, a NaN here resumes training from
|
|
355
|
+
# NaN and the run still reports success.
|
|
356
|
+
require_finite(weights, f"resume_state: weights artifact {key!r}")
|
|
357
|
+
return r + 1, weights, f"artifact://{key}"
|
|
358
|
+
|
|
359
|
+
if pairs and min(r for r, _ in pairs) > 0:
|
|
360
|
+
# Every carried job is from a resumed run and none of them
|
|
361
|
+
# aggregated, so rounds before the earliest carried one may well
|
|
362
|
+
# have completed under jobs this list does not mention. "Start over
|
|
363
|
+
# from round 0" would throw that work away silently; say so instead.
|
|
364
|
+
raise RuntimeError(
|
|
365
|
+
"no completed round found, but the carried history starts at "
|
|
366
|
+
f"round {min(r for r, _ in pairs)}: earlier rounds ran under job "
|
|
367
|
+
"ids not present here, so 'restart from scratch' cannot be "
|
|
368
|
+
"concluded. Pass the full (round, job_id) history."
|
|
369
|
+
)
|
|
370
|
+
return 0, {}, None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _retrying(call: Callable[[], Any], *, what: str, deadline: float,
|
|
374
|
+
attempts: int, backoff_s: float) -> Any:
|
|
375
|
+
"""Call `call()`, retrying transient failures with capped backoff.
|
|
376
|
+
|
|
377
|
+
A multi-round run is hours long; one blip from the coordinator (a
|
|
378
|
+
restart, a dropped connection, a 502 from a proxy) must not end it —
|
|
379
|
+
flashnode's own executor loop backs off and keeps going, and a driver
|
|
380
|
+
that does not is the weakest link in the pair. Bounded, though: after
|
|
381
|
+
`attempts` tries it fails with context rather than looping forever, and
|
|
382
|
+
it never sleeps past the round deadline.
|
|
383
|
+
"""
|
|
384
|
+
last: BaseException | None = None
|
|
385
|
+
for attempt in range(1, attempts + 1):
|
|
386
|
+
try:
|
|
387
|
+
return call()
|
|
388
|
+
except Exception as exc: # noqa: BLE001 - re-raised with context below
|
|
389
|
+
last = exc
|
|
390
|
+
remaining = deadline - time.monotonic()
|
|
391
|
+
if attempt == attempts or remaining <= 0:
|
|
392
|
+
break
|
|
393
|
+
time.sleep(min(backoff_s * (2 ** (attempt - 1)), 5.0, remaining))
|
|
394
|
+
raise CoordinatorUnavailable(
|
|
395
|
+
f"{what}: coordinator call failed after {attempt} attempt(s): {last!r}"
|
|
396
|
+
) from last
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def run_fedavg(
|
|
400
|
+
coord: Coordinator,
|
|
401
|
+
*,
|
|
402
|
+
rounds: int,
|
|
403
|
+
num_shards: int,
|
|
404
|
+
min_participants: int,
|
|
405
|
+
worker_params: dict,
|
|
406
|
+
initial_weights: dict,
|
|
407
|
+
round_timeout_s: float = 600.0,
|
|
408
|
+
poll_seconds: float = 1.0,
|
|
409
|
+
lease_seconds: float = 120.0,
|
|
410
|
+
on_round: Callable[[RoundResult], None] | None = None,
|
|
411
|
+
start_round: int = 0,
|
|
412
|
+
weights_uri: str | None = None,
|
|
413
|
+
image: str = DEFAULT_IMAGE,
|
|
414
|
+
isolation_tier: str = "standard",
|
|
415
|
+
allow_fallback: bool = False,
|
|
416
|
+
poll_attempts: int = 4,
|
|
417
|
+
poll_backoff_s: float = 0.5,
|
|
418
|
+
prior_job_ids: Sequence[tuple[int, str]] | None = None,
|
|
419
|
+
build_round: BuildRound | None = None,
|
|
420
|
+
) -> dict:
|
|
421
|
+
"""Drive `rounds` federated-averaging rounds and return the final weights.
|
|
422
|
+
|
|
423
|
+
`image` and `isolation_tier` are caller-settable rather than hardcoded:
|
|
424
|
+
the default `local/tier1:dev` exists only in this repo's e2e fixtures,
|
|
425
|
+
and it is inert today only because `SubprocessRunner` ignores `image`
|
|
426
|
+
entirely. The moment a round is served by a docker-tier volunteer, a
|
|
427
|
+
hardcoded image is an unpullable reference on somebody else's machine —
|
|
428
|
+
the same "two places, each correct in isolation" shape as the task-module
|
|
429
|
+
allowlist drift that already caused an outage here.
|
|
430
|
+
|
|
431
|
+
`build_round` replaces how a round becomes a job. The default builds the
|
|
432
|
+
built-in `federated_averaging` body, whose tasks run
|
|
433
|
+
`flashml_workloads.fedavg_worker`. A caller that wants the *user's own*
|
|
434
|
+
code to be the round worker — the cloud API compiling a repo's
|
|
435
|
+
entrypoint into a `command` job per round — passes its own builder
|
|
436
|
+
instead; everything downstream (quorum, reduce, weights artifact,
|
|
437
|
+
resume) is unchanged, because none of it depends on what ran inside the
|
|
438
|
+
round, only on the task ids it produced and the `metrics.json` /
|
|
439
|
+
`delta.json` pair each one committed. `worker_params`, `image`,
|
|
440
|
+
`isolation_tier`, `allow_fallback` and `lease_seconds` are inputs to the
|
|
441
|
+
*default* builder and are ignored when `build_round` is supplied — the
|
|
442
|
+
builder already knows all of it.
|
|
443
|
+
|
|
444
|
+
`initial_weights` may be `{}`, and that is not the same as "start from
|
|
445
|
+
zeros": it means the driver holds no weights yet, so round 0's reduced
|
|
446
|
+
contribution IS the first set of weights rather than a delta applied to
|
|
447
|
+
something. That is the only reading consistent with the worker contract
|
|
448
|
+
("`delta.json` is the change from the weights you were given") when a
|
|
449
|
+
worker was given no weights — and it is the case that matters for
|
|
450
|
+
arbitrary user code, where the API cannot construct the model to
|
|
451
|
+
initialise from. Passing a real `initial_weights` (as
|
|
452
|
+
`fedavg_worker`-based callers do, seeding from the model) keeps the
|
|
453
|
+
previous behaviour exactly.
|
|
454
|
+
|
|
455
|
+
Returns `{"weights", "history", "job_ids"}` where `job_ids` is a list of
|
|
456
|
+
`(round, job_id)` pairs suitable for feeding straight back into
|
|
457
|
+
`resume_state` (and into `prior_job_ids` on the next resume).
|
|
458
|
+
"""
|
|
459
|
+
if min_participants < 1:
|
|
460
|
+
raise ValueError("min_participants must be >= 1")
|
|
461
|
+
if min_participants > num_shards:
|
|
462
|
+
raise ValueError(
|
|
463
|
+
f"min_participants {min_participants} exceeds num_shards {num_shards}"
|
|
464
|
+
)
|
|
465
|
+
if poll_attempts < 1:
|
|
466
|
+
raise ValueError("poll_attempts must be >= 1")
|
|
467
|
+
|
|
468
|
+
weights = initial_weights
|
|
469
|
+
history: list[RoundResult] = []
|
|
470
|
+
# (round, job_id), never a bare list whose position implies the round:
|
|
471
|
+
# a resumed run's first entry is round `start_round`, not round 0.
|
|
472
|
+
job_ids: list[tuple[int, str]] = [(int(r), str(j))
|
|
473
|
+
for r, j in (prior_job_ids or [])]
|
|
474
|
+
|
|
475
|
+
for r in range(start_round, rounds):
|
|
476
|
+
if build_round is None:
|
|
477
|
+
plan: RoundPlan = {
|
|
478
|
+
"body": _round_body(r, num_shards, worker_params, weights_uri,
|
|
479
|
+
lease_seconds, image, isolation_tier,
|
|
480
|
+
allow_fallback),
|
|
481
|
+
"task_ids": _default_task_ids(num_shards),
|
|
482
|
+
}
|
|
483
|
+
else:
|
|
484
|
+
plan = build_round(r, weights_uri)
|
|
485
|
+
task_ids = list(plan["task_ids"])
|
|
486
|
+
if len(task_ids) != len(set(task_ids)):
|
|
487
|
+
# Duplicate ids would collapse two expected keys into one and
|
|
488
|
+
# silently lower the achievable participant count below
|
|
489
|
+
# min_participants — a round that can never reach quorum.
|
|
490
|
+
raise ValueError(
|
|
491
|
+
f"round {r}: build_round returned duplicate task ids {task_ids!r}"
|
|
492
|
+
)
|
|
493
|
+
if len(task_ids) < min_participants:
|
|
494
|
+
raise ValueError(
|
|
495
|
+
f"round {r}: build_round returned {len(task_ids)} task(s), "
|
|
496
|
+
f"fewer than min_participants {min_participants} — quorum "
|
|
497
|
+
"could never be reached"
|
|
498
|
+
)
|
|
499
|
+
job_id = coord.submit(plan["body"])["job_id"]
|
|
500
|
+
job_ids.append((r, job_id))
|
|
501
|
+
|
|
502
|
+
deadline = time.monotonic() + round_timeout_s
|
|
503
|
+
keys: list[str] = []
|
|
504
|
+
while True:
|
|
505
|
+
keys = _retrying(
|
|
506
|
+
lambda: _committed_metrics_keys(coord, job_id, task_ids),
|
|
507
|
+
what=f"round {r}: listing committed shards",
|
|
508
|
+
deadline=deadline, attempts=poll_attempts,
|
|
509
|
+
backoff_s=poll_backoff_s,
|
|
510
|
+
)
|
|
511
|
+
if len(keys) >= min_participants:
|
|
512
|
+
break
|
|
513
|
+
state = _retrying(
|
|
514
|
+
lambda: coord.job_state(job_id),
|
|
515
|
+
what=f"round {r}: reading job state",
|
|
516
|
+
deadline=deadline, attempts=poll_attempts,
|
|
517
|
+
backoff_s=poll_backoff_s,
|
|
518
|
+
)
|
|
519
|
+
if state in ("FAILED", "CANCELLED"):
|
|
520
|
+
raise QuorumNotMet(
|
|
521
|
+
f"round {r}: job {job_id} ended {state} with "
|
|
522
|
+
f"{len(keys)} of {min_participants} needed"
|
|
523
|
+
)
|
|
524
|
+
if time.monotonic() > deadline:
|
|
525
|
+
raise QuorumNotMet(
|
|
526
|
+
f"round {r}: timed out with {len(keys)} of "
|
|
527
|
+
f"{min_participants} needed ({len(task_ids)} shards dispatched)"
|
|
528
|
+
)
|
|
529
|
+
# Clamp to the time remaining: if round_timeout_s < poll_seconds
|
|
530
|
+
# a full un-clamped sleep would overrun the deadline by up to
|
|
531
|
+
# one poll tick before the loop gets a chance to re-check it.
|
|
532
|
+
time.sleep(min(poll_seconds, max(0.0, deadline - time.monotonic())))
|
|
533
|
+
|
|
534
|
+
# Freeze the participant set at the moment quorum was reached, then
|
|
535
|
+
# download. Anything committing from here on is discarded by
|
|
536
|
+
# construction: we never re-read this job after aggregating.
|
|
537
|
+
collected = _fetch(coord, keys)
|
|
538
|
+
reduced = reduce_deltas([(d, n) for d, n, _ in collected])
|
|
539
|
+
# No weights yet (`initial_weights={}` and nothing aggregated): the
|
|
540
|
+
# round's workers were handed nothing, so what they reported as
|
|
541
|
+
# "the change from what you were given" is the weights themselves.
|
|
542
|
+
# `apply_delta` would refuse here — an empty base and a populated
|
|
543
|
+
# delta are, correctly, not the same parameter set.
|
|
544
|
+
# `require_finite` on the bootstrap branch because `apply_delta` —
|
|
545
|
+
# the only other way out of here — checks its own result, and the
|
|
546
|
+
# weighted sum of finite contributions can still overflow to inf.
|
|
547
|
+
weights = (require_finite(reduced, f"round {r}: bootstrap weights")
|
|
548
|
+
if not weights else apply_delta(weights, reduced))
|
|
549
|
+
|
|
550
|
+
weights_key = f"jobs/{job_id}/round-{r:03d}/weights.json"
|
|
551
|
+
coord.put_artifact(weights_key, weights)
|
|
552
|
+
weights_uri = f"artifact://{weights_key}"
|
|
553
|
+
|
|
554
|
+
# Sample-weighted, consistent with the delta reduce: an unweighted
|
|
555
|
+
# mean would let a low-sample straggler with high loss skew the
|
|
556
|
+
# reported metric out of proportion to its actual contribution to
|
|
557
|
+
# the aggregate weights.
|
|
558
|
+
total_n = sum(n for _, n, _ in collected)
|
|
559
|
+
result: RoundResult = {
|
|
560
|
+
"round": r,
|
|
561
|
+
"participants": len(collected),
|
|
562
|
+
"mean_loss": sum(loss * n for _, n, loss in collected) / total_n,
|
|
563
|
+
"job_id": job_id,
|
|
564
|
+
}
|
|
565
|
+
history.append(result)
|
|
566
|
+
if on_round is not None:
|
|
567
|
+
on_round(result)
|
|
568
|
+
|
|
569
|
+
return {"weights": weights, "history": history, "job_ids": job_ids}
|