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.
Files changed (95) hide show
  1. flashml_workloads/__init__.py +7 -0
  2. flashml_workloads/fedavg_driver.py +569 -0
  3. flashml_workloads/fedavg_weights.py +223 -0
  4. flashml_workloads/fedavg_worker.py +166 -0
  5. flashml_workloads/kmeans_driver.py +134 -0
  6. flashml_workloads/kmeans_shard.py +69 -0
  7. flashml_workloads/sgd_trainer.py +127 -0
  8. flashml_workloads/sharded_kmeans.py +323 -0
  9. flashml_workloads/sklearn_trial.py +89 -0
  10. flashruntime/__init__.py +125 -0
  11. flashruntime/artifacts/__init__.py +25 -0
  12. flashruntime/artifacts/store.py +228 -0
  13. flashruntime/backends/__init__.py +26 -0
  14. flashruntime/backends/base.py +63 -0
  15. flashruntime/backends/kuberay.py +465 -0
  16. flashruntime/checkpoint/__init__.py +20 -0
  17. flashruntime/checkpoint/catalog.py +198 -0
  18. flashruntime/checkpoint/local.py +109 -0
  19. flashruntime/checkpoint/store.py +86 -0
  20. flashruntime/integrations/__init__.py +5 -0
  21. flashruntime/integrations/huggingface.py +59 -0
  22. flashruntime/integrations/pytorch.py +52 -0
  23. flashruntime/integrations/sklearn.py +42 -0
  24. flashruntime/launchers/__init__.py +130 -0
  25. flashruntime/launchers/local.py +126 -0
  26. flashruntime/leases/__init__.py +27 -0
  27. flashruntime/leases/manager.py +365 -0
  28. flashruntime/leases/sqlite_store.py +169 -0
  29. flashruntime/leases/store.py +103 -0
  30. flashruntime/monitor/__init__.py +7 -0
  31. flashruntime/monitor/sampler.py +232 -0
  32. flashruntime/planner/__init__.py +56 -0
  33. flashruntime/planner/candidates.py +597 -0
  34. flashruntime/planner/catalog.py +129 -0
  35. flashruntime/planner/comm.py +95 -0
  36. flashruntime/planner/explain.py +109 -0
  37. flashruntime/planner/memory.py +166 -0
  38. flashruntime/planner/resolve.py +120 -0
  39. flashruntime/planner/selector.py +169 -0
  40. flashruntime/planner/timecost.py +81 -0
  41. flashruntime/profiling/__init__.py +113 -0
  42. flashruntime/protocol/__init__.py +18 -0
  43. flashruntime/protocol/plan_v1alpha1.py +320 -0
  44. flashruntime/protocol/v1alpha1.py +465 -0
  45. flashruntime/providers/__init__.py +138 -0
  46. flashruntime/py.typed +0 -0
  47. flashruntime/recipes/__init__.py +135 -0
  48. flashruntime/recipes/command.py +166 -0
  49. flashruntime/recovery/__init__.py +21 -0
  50. flashruntime/recovery/policy.py +170 -0
  51. flashruntime/recovery/signals.py +135 -0
  52. flashruntime/recovery/taxonomy.py +91 -0
  53. flashruntime/scheduler/__init__.py +170 -0
  54. flashruntime/sdk.py +402 -0
  55. flashruntime/service/__init__.py +3 -0
  56. flashruntime/service/app.py +391 -0
  57. flashruntime/service/auth.py +180 -0
  58. flashruntime/service/checkpoints.py +90 -0
  59. flashruntime/service/cli.py +167 -0
  60. flashruntime/service/dashboard.py +193 -0
  61. flashruntime/service/ledger.py +101 -0
  62. flashruntime/service/modea.py +821 -0
  63. flashruntime/strategies/__init__.py +156 -0
  64. flashruntime/strategies/command.py +56 -0
  65. flashruntime/torch/__init__.py +274 -0
  66. flashruntime/viewer/__init__.py +20 -0
  67. flashruntime/viewer/_docs/benchmarks.html +771 -0
  68. flashruntime/viewer/_docs/concepts/architecture.html +302 -0
  69. flashruntime/viewer/_docs/get-started.html +263 -0
  70. flashruntime/viewer/_docs/guides/federated-averaging.html +363 -0
  71. flashruntime/viewer/_docs/guides/huggingface.html +223 -0
  72. flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +271 -0
  73. flashruntime/viewer/_docs/guides/pytorch.html +313 -0
  74. flashruntime/viewer/_docs/guides/sklearn.html +232 -0
  75. flashruntime/viewer/_docs/index.html +251 -0
  76. flashruntime/viewer/_docs/reference/cli.html +254 -0
  77. flashruntime/viewer/_docs/reference/integrations.html +240 -0
  78. flashruntime/viewer/_docs/reference/sdk.html +341 -0
  79. flashruntime/viewer/_docs/reference/torch-helper.html +244 -0
  80. flashruntime/viewer/_docs/search-index.json +1 -0
  81. flashruntime/viewer/_docs/tutorials/convnet.html +571 -0
  82. flashruntime/viewer/_docs/tutorials/fault-tolerance.html +375 -0
  83. flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +278 -0
  84. flashruntime/viewer/flowmap.py +307 -0
  85. flashruntime/viewer/page.py +594 -0
  86. flashruntime/viewer/server.py +134 -0
  87. flashruntime/viewer/state.py +250 -0
  88. flashruntime/workloads/__init__.py +6 -0
  89. flashruntime/workloads/command.py +127 -0
  90. flashruntime-0.3.0.dist-info/METADATA +365 -0
  91. flashruntime-0.3.0.dist-info/RECORD +95 -0
  92. flashruntime-0.3.0.dist-info/WHEEL +5 -0
  93. flashruntime-0.3.0.dist-info/entry_points.txt +2 -0
  94. flashruntime-0.3.0.dist-info/licenses/LICENSE +202 -0
  95. 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}