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,223 @@
|
|
|
1
|
+
"""Weight/delta encoding and the federated-averaging reduce.
|
|
2
|
+
|
|
3
|
+
Weights cross the wire as JSON so the driver never imports torch: it runs
|
|
4
|
+
inside the cloud API (spec §5.4.5), which must stay a light service. Only
|
|
5
|
+
`fedavg_worker` needs torch, and it converts at the boundary.
|
|
6
|
+
|
|
7
|
+
{"<param>": {"shape": [int, ...], "data": [float, ...]}}
|
|
8
|
+
|
|
9
|
+
`data` is the flattened tensor in row-major order; `shape` restores it.
|
|
10
|
+
Pure stdlib on purpose — same rule as kmeans_shard and sgd_trainer, so
|
|
11
|
+
this module runs on any device, including inside a --network none
|
|
12
|
+
container.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"NonFiniteWeights",
|
|
21
|
+
"WeightShapeMismatch",
|
|
22
|
+
"apply_delta",
|
|
23
|
+
"decode",
|
|
24
|
+
"encode",
|
|
25
|
+
"reduce_deltas",
|
|
26
|
+
"require_finite",
|
|
27
|
+
"subtract",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class WeightShapeMismatch(ValueError):
|
|
32
|
+
"""Two weight blobs do not describe the same parameter set.
|
|
33
|
+
|
|
34
|
+
Never coerce past this: averaging mismatched blobs would emit weights
|
|
35
|
+
that load fine and train to nonsense.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NonFiniteWeights(ValueError):
|
|
40
|
+
"""A weight/delta blob contains NaN or +/-Infinity.
|
|
41
|
+
|
|
42
|
+
This is not a paranoid check, and it is not primarily about attackers:
|
|
43
|
+
Python's `json` module both EMITS and PARSES the non-standard `NaN`,
|
|
44
|
+
`Infinity` and `-Infinity` literals, so a single shard whose learning
|
|
45
|
+
rate diverged writes `NaN` into its delta, the reduce turns every
|
|
46
|
+
weight into `NaN`, every later round trains from `NaN` weights, and the
|
|
47
|
+
run still reports success. There is no recovering from it afterwards —
|
|
48
|
+
NaN is absorbing — so it has to fail at the boundary where it enters.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def require_finite(blob: dict, where: str) -> dict:
|
|
53
|
+
"""Raise `NonFiniteWeights` if any value in `blob` is NaN or Inf.
|
|
54
|
+
|
|
55
|
+
Named (not `_private`) because it is the containment boundary every
|
|
56
|
+
caller of this module is entitled to use; the message names the
|
|
57
|
+
parameter and the flat index so an operator can point at the shard that
|
|
58
|
+
diverged rather than bisecting a megabyte of JSON.
|
|
59
|
+
"""
|
|
60
|
+
for name, param in blob.items():
|
|
61
|
+
for i, v in enumerate(param["data"]):
|
|
62
|
+
try:
|
|
63
|
+
finite = math.isfinite(v)
|
|
64
|
+
except TypeError: # a node sent a string/null where a float belongs
|
|
65
|
+
finite = False
|
|
66
|
+
if not finite:
|
|
67
|
+
raise NonFiniteWeights(
|
|
68
|
+
f"{where}: parameter {name!r} index {i} is not a finite "
|
|
69
|
+
f"number ({v!r})"
|
|
70
|
+
)
|
|
71
|
+
return blob
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def encode(state: dict[str, tuple[list[int], list[float]]]) -> dict:
|
|
75
|
+
return {name: {"shape": list(shape), "data": list(data)}
|
|
76
|
+
for name, (shape, data) in state.items()}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def decode(blob: dict) -> dict[str, tuple[list[int], list[float]]]:
|
|
80
|
+
return {name: (list(p["shape"]), list(p["data"])) for name, p in blob.items()}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _require_same_params(a: dict, b: dict) -> None:
|
|
84
|
+
if a.keys() != b.keys():
|
|
85
|
+
raise WeightShapeMismatch(
|
|
86
|
+
f"parameter names differ: {sorted(a.keys())} vs {sorted(b.keys())}"
|
|
87
|
+
)
|
|
88
|
+
for name in a:
|
|
89
|
+
shape_a = a[name]["shape"]
|
|
90
|
+
shape_b = b[name]["shape"]
|
|
91
|
+
|
|
92
|
+
if shape_a != shape_b:
|
|
93
|
+
raise WeightShapeMismatch(
|
|
94
|
+
f"parameter {name!r} shape {shape_a} vs {shape_b}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Both shapes are the same; compute expected data length.
|
|
98
|
+
# Empty shape (scalar) has product 1.
|
|
99
|
+
expected_len = math.prod(shape_a) if shape_a else 1
|
|
100
|
+
|
|
101
|
+
# Validate that data length matches declared shape in both blobs.
|
|
102
|
+
data_len_a = len(a[name]["data"])
|
|
103
|
+
if data_len_a != expected_len:
|
|
104
|
+
raise WeightShapeMismatch(
|
|
105
|
+
f"parameter {name!r} declared shape {shape_a} (product {expected_len}) but data has length {data_len_a}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
data_len_b = len(b[name]["data"])
|
|
109
|
+
if data_len_b != expected_len:
|
|
110
|
+
raise WeightShapeMismatch(
|
|
111
|
+
f"parameter {name!r} declared shape {shape_b} (product {expected_len}) but data has length {data_len_b}"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def subtract(new: dict, base: dict) -> dict:
|
|
116
|
+
_require_same_params(new, base)
|
|
117
|
+
# Checked on the RESULT, not the inputs: it is the cheapest single place
|
|
118
|
+
# that catches a diverged local step (NaN weights) before the delta is
|
|
119
|
+
# uploaded, and NaN/Inf in either input propagates into the difference.
|
|
120
|
+
return require_finite({
|
|
121
|
+
name: {
|
|
122
|
+
"shape": list(new[name]["shape"]),
|
|
123
|
+
"data": [x - y for x, y in zip(new[name]["data"], base[name]["data"])],
|
|
124
|
+
}
|
|
125
|
+
for name in new
|
|
126
|
+
}, "subtract")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def apply_delta(base: dict, delta: dict, scale: float = 1.0) -> dict:
|
|
130
|
+
_require_same_params(base, delta)
|
|
131
|
+
return require_finite({
|
|
132
|
+
name: {
|
|
133
|
+
"shape": list(base[name]["shape"]),
|
|
134
|
+
"data": [b + scale * d
|
|
135
|
+
for b, d in zip(base[name]["data"], delta[name]["data"])],
|
|
136
|
+
}
|
|
137
|
+
for name in base
|
|
138
|
+
}, "apply_delta")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def reduce_deltas(contributions: list[tuple[dict, int]]) -> dict:
|
|
142
|
+
"""Sample-weighted mean of per-worker deltas (FedAvg).
|
|
143
|
+
|
|
144
|
+
Weighting by sample count, not by worker, is what keeps the result
|
|
145
|
+
equal to centralized training on the union of the shards when the
|
|
146
|
+
shards are unequal — which they always are once machines differ.
|
|
147
|
+
"""
|
|
148
|
+
if not contributions:
|
|
149
|
+
raise ValueError("reduce_deltas: no contributions")
|
|
150
|
+
total = sum(n for _, n in contributions)
|
|
151
|
+
if total < 0:
|
|
152
|
+
raise ValueError("reduce_deltas: negative total samples")
|
|
153
|
+
if total == 0:
|
|
154
|
+
raise ValueError("reduce_deltas: zero total samples")
|
|
155
|
+
|
|
156
|
+
# Validating only the TOTAL is not enough, and the gap is a model-
|
|
157
|
+
# poisoning primitive rather than a hygiene nit. `samples` is chosen by
|
|
158
|
+
# an untrusted volunteer node. With contributions (delta=-999, n=-999)
|
|
159
|
+
# and (delta=1.0, n=1000) the total is a healthy 1, but the weights
|
|
160
|
+
# w = n/total are -999 and 1000, so the "average" of two updates of
|
|
161
|
+
# magnitude ~1 is 999001.0 — six orders of magnitude outside the convex
|
|
162
|
+
# hull the average is supposed to stay inside. A weight is only a convex
|
|
163
|
+
# combination when every count is positive, so reject non-positive
|
|
164
|
+
# counts outright. (Ordered AFTER the total guard so an all-zero
|
|
165
|
+
# contribution set still reports the clearer "zero total samples".)
|
|
166
|
+
for i, (_, n) in enumerate(contributions):
|
|
167
|
+
# A NaN/Inf sample count slips BOTH the total guard above and the
|
|
168
|
+
# `n <= 0` guard below, because every comparison against NaN is
|
|
169
|
+
# False: `total <= 0`, `total == 0` and `n <= 0` are all False for a
|
|
170
|
+
# NaN contribution. It happens to be caught downstream by the
|
|
171
|
+
# finiteness check on the reduced result, but only by accident, not
|
|
172
|
+
# by design, so it is rejected explicitly here, by index, before it
|
|
173
|
+
# can reach that accidental safety net.
|
|
174
|
+
if isinstance(n, float) and not math.isfinite(n):
|
|
175
|
+
raise ValueError(
|
|
176
|
+
f"reduce_deltas: contribution {i} has a non-finite sample "
|
|
177
|
+
f"count {n!r}; sample counts must be a finite positive "
|
|
178
|
+
"integer"
|
|
179
|
+
)
|
|
180
|
+
# `bool` is a subclass of `int` (`True == 1`, `False == 0`), so a
|
|
181
|
+
# bool sample count would otherwise slide through silently. A
|
|
182
|
+
# sample count is a count of training examples, not a flag, and
|
|
183
|
+
# this boundary receives untrusted JSON from a volunteer node where
|
|
184
|
+
# `true`/`false` is a plausible malformed value for a field that
|
|
185
|
+
# should be an integer — reject it rather than coerce it. Likewise
|
|
186
|
+
# a non-integer float (e.g. `2.5`) does not correspond to any real
|
|
187
|
+
# shard size; it happens not to break the convex-combination math
|
|
188
|
+
# below, but silently accepting it is the same kind of
|
|
189
|
+
# accidental-safety gap the NaN/Inf case above is about.
|
|
190
|
+
if isinstance(n, bool) or not isinstance(n, int):
|
|
191
|
+
raise ValueError(
|
|
192
|
+
f"reduce_deltas: contribution {i} has a non-integer sample "
|
|
193
|
+
f"count {n!r} (type {type(n).__name__}); sample counts must "
|
|
194
|
+
"be a plain positive int, not a bool or a fractional float"
|
|
195
|
+
)
|
|
196
|
+
if n <= 0:
|
|
197
|
+
raise ValueError(
|
|
198
|
+
f"reduce_deltas: contribution {i} has non-positive sample "
|
|
199
|
+
f"count {n!r}; sample counts must be > 0 (a negative or zero "
|
|
200
|
+
"count makes the sample weight fall outside [0, 1] and lets "
|
|
201
|
+
"one shard amplify its delta arbitrarily)"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
first = contributions[0][0]
|
|
205
|
+
# Validate first blob's internal consistency and all subsequent blobs,
|
|
206
|
+
# and reject NaN/Inf on the way IN: one NaN anywhere in one shard's delta
|
|
207
|
+
# makes every output weight NaN, and every subsequent round then trains
|
|
208
|
+
# from NaN. Rejecting the round is recoverable; poisoning the model is not.
|
|
209
|
+
for i, (blob, _) in enumerate(contributions):
|
|
210
|
+
# i == 0 validates the first blob against itself (internal
|
|
211
|
+
# shape/data-length consistency), the rest against the first.
|
|
212
|
+
_require_same_params(first, blob)
|
|
213
|
+
require_finite(blob, f"reduce_deltas: contribution {i}")
|
|
214
|
+
|
|
215
|
+
out: dict = {}
|
|
216
|
+
for name in first:
|
|
217
|
+
acc = [0.0] * len(first[name]["data"])
|
|
218
|
+
for blob, n in contributions:
|
|
219
|
+
w = n / total
|
|
220
|
+
for i, v in enumerate(blob[name]["data"]):
|
|
221
|
+
acc[i] += w * v
|
|
222
|
+
out[name] = {"shape": list(first[name]["shape"]), "data": acc}
|
|
223
|
+
return out
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""One federated-averaging round on one shard, runnable as a leased task.
|
|
2
|
+
|
|
3
|
+
Executor contract (same as kmeans_shard / sgd_trainer):
|
|
4
|
+
|
|
5
|
+
python -m flashml_workloads.fedavg_worker --spec spec.json --out OUTDIR
|
|
6
|
+
|
|
7
|
+
`spec.json`:
|
|
8
|
+
params: round, shard, num_shards, local_steps, lr, batch_size, seed,
|
|
9
|
+
in_dim, hidden, out_dim, dataset_size
|
|
10
|
+
inputs: weights (optional path to the round's weights JSON; absent on
|
|
11
|
+
round 0, where the seed determines the starting point)
|
|
12
|
+
|
|
13
|
+
Outputs `OUTDIR/delta.json` (this worker's weight change) and
|
|
14
|
+
`OUTDIR/metrics.json` (the commit artifact — only a root-level
|
|
15
|
+
metrics.json sets the commit hash).
|
|
16
|
+
|
|
17
|
+
Why a delta and not the new weights: the driver averages contributions,
|
|
18
|
+
and averaging deltas keeps the arithmetic correct when a worker joins a
|
|
19
|
+
round late with stale weights — its delta is still a valid direction from
|
|
20
|
+
the weights it actually saw.
|
|
21
|
+
|
|
22
|
+
torch is imported inside functions so this module can be inspected (and
|
|
23
|
+
the rest of flashml_workloads used) without torch installed.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import json
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from flashml_workloads.fedavg_weights import decode, encode, require_finite, subtract
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_model(seed: int, in_dim: int, hidden: int, out_dim: int):
|
|
36
|
+
import torch
|
|
37
|
+
from torch import nn
|
|
38
|
+
|
|
39
|
+
torch.manual_seed(seed)
|
|
40
|
+
return nn.Sequential(
|
|
41
|
+
nn.Linear(in_dim, hidden), nn.ReLU(), nn.Linear(hidden, out_dim)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def state_to_blob(model) -> dict:
|
|
46
|
+
state = {}
|
|
47
|
+
for name, t in model.state_dict().items():
|
|
48
|
+
state[name] = (list(t.shape), [float(x) for x in t.flatten().tolist()])
|
|
49
|
+
return encode(state)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def blob_to_state(model, blob: dict) -> None:
|
|
53
|
+
"""Load a weight blob into `model`, failing loudly on any mismatch."""
|
|
54
|
+
import torch
|
|
55
|
+
|
|
56
|
+
from flashml_workloads.fedavg_weights import WeightShapeMismatch
|
|
57
|
+
|
|
58
|
+
# This is the worker's entry point for weights that arrived over the
|
|
59
|
+
# network as a staged input file — the last line of defence on the node
|
|
60
|
+
# side before a NaN/Inf is loaded straight into the torch model. Gate it
|
|
61
|
+
# before anything else so a corrupted or attacker-written weights.json
|
|
62
|
+
# never reaches `load_state_dict`.
|
|
63
|
+
require_finite(blob, "blob_to_state")
|
|
64
|
+
|
|
65
|
+
current = state_to_blob(model)
|
|
66
|
+
if current.keys() != blob.keys():
|
|
67
|
+
raise WeightShapeMismatch(
|
|
68
|
+
f"weights name mismatch: expected {sorted(current)}, got {sorted(blob)}"
|
|
69
|
+
)
|
|
70
|
+
new_state = {}
|
|
71
|
+
for name, (shape, data) in decode(blob).items():
|
|
72
|
+
if shape != list(current[name]["shape"]):
|
|
73
|
+
raise WeightShapeMismatch(
|
|
74
|
+
f"parameter {name!r} shape {shape} != {current[name]['shape']}"
|
|
75
|
+
)
|
|
76
|
+
new_state[name] = torch.tensor(data, dtype=torch.float32).reshape(shape)
|
|
77
|
+
model.load_state_dict(new_state)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _make_shard(params: dict):
|
|
81
|
+
"""Deterministic synthetic data, sliced by shard.
|
|
82
|
+
|
|
83
|
+
Strided slicing (`x[shard::num_shards]`) rather than contiguous blocks
|
|
84
|
+
so every shard sees the same label distribution — a contiguous split of
|
|
85
|
+
sorted data would give workers disjoint classes and FedAvg would
|
|
86
|
+
diverge for reasons unrelated to the runtime.
|
|
87
|
+
"""
|
|
88
|
+
import torch
|
|
89
|
+
|
|
90
|
+
g = torch.Generator().manual_seed(params["seed"])
|
|
91
|
+
n, d = params["dataset_size"], params["in_dim"]
|
|
92
|
+
x = torch.randn(n, d, generator=g)
|
|
93
|
+
w = torch.randn(d, 1, generator=g)
|
|
94
|
+
y = ((x @ w).squeeze(1) > 0).long()
|
|
95
|
+
shard, num = params["shard"], params["num_shards"]
|
|
96
|
+
return x[shard::num], y[shard::num]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def run_worker(spec: dict, outdir: Path) -> dict:
|
|
100
|
+
import torch
|
|
101
|
+
from torch import nn
|
|
102
|
+
|
|
103
|
+
p = spec["params"]
|
|
104
|
+
outdir = Path(outdir)
|
|
105
|
+
|
|
106
|
+
model = build_model(p["seed"], p["in_dim"], p["hidden"], p["out_dim"])
|
|
107
|
+
weights_path = (spec.get("inputs") or {}).get("weights")
|
|
108
|
+
if weights_path:
|
|
109
|
+
blob_to_state(model, json.loads(Path(weights_path).read_text()))
|
|
110
|
+
base = state_to_blob(model)
|
|
111
|
+
|
|
112
|
+
x, y = _make_shard(p)
|
|
113
|
+
samples = int(x.shape[0])
|
|
114
|
+
if samples == 0:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"shard {p['shard']} of num_shards={p['num_shards']} is empty "
|
|
117
|
+
f"for dataset_size={p['dataset_size']}: num_shards must not "
|
|
118
|
+
f"exceed dataset_size"
|
|
119
|
+
)
|
|
120
|
+
opt = torch.optim.SGD(model.parameters(), lr=p["lr"])
|
|
121
|
+
loss_fn = nn.CrossEntropyLoss()
|
|
122
|
+
batch = p["batch_size"]
|
|
123
|
+
|
|
124
|
+
# Batches are indexed by step via a wrapped, step-indexed gather (each
|
|
125
|
+
# index individually wrapped mod `samples`, not a single truncating
|
|
126
|
+
# slice) so every step sees exactly `batch` rows even when `batch` does
|
|
127
|
+
# not divide `samples` evenly — same rule as sgd_trainer. Pure function
|
|
128
|
+
# of `step`, no RNG state, so a retried attempt reproduces the same delta.
|
|
129
|
+
last_loss = 0.0
|
|
130
|
+
for step in range(p["local_steps"]):
|
|
131
|
+
idx = [(step * batch + i) % samples for i in range(batch)]
|
|
132
|
+
xb, yb = x[idx], y[idx]
|
|
133
|
+
opt.zero_grad()
|
|
134
|
+
loss = loss_fn(model(xb), yb)
|
|
135
|
+
loss.backward()
|
|
136
|
+
opt.step()
|
|
137
|
+
last_loss = float(loss.item())
|
|
138
|
+
|
|
139
|
+
delta = subtract(state_to_blob(model), base)
|
|
140
|
+
(outdir / "delta.json").write_text(json.dumps(delta))
|
|
141
|
+
|
|
142
|
+
metrics = {
|
|
143
|
+
"round": p["round"],
|
|
144
|
+
"shard": p["shard"],
|
|
145
|
+
"samples": samples,
|
|
146
|
+
"loss": last_loss,
|
|
147
|
+
"local_steps": p["local_steps"],
|
|
148
|
+
"delta_file": "delta.json",
|
|
149
|
+
}
|
|
150
|
+
(outdir / "metrics.json").write_text(json.dumps(metrics, sort_keys=True))
|
|
151
|
+
return metrics
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def main() -> int:
|
|
155
|
+
ap = argparse.ArgumentParser()
|
|
156
|
+
ap.add_argument("--spec", required=True)
|
|
157
|
+
ap.add_argument("--out", required=True)
|
|
158
|
+
args = ap.parse_args()
|
|
159
|
+
outdir = Path(args.out)
|
|
160
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
run_worker(json.loads(Path(args.spec).read_text()), outdir)
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""K-means driver: composes lease jobs into Lloyd's algorithm.
|
|
2
|
+
|
|
3
|
+
One iteration = one Mode A job (N independent shard tasks); the driver
|
|
4
|
+
reduces the shard partials into new centroids and submits the next
|
|
5
|
+
iteration. This is the stage-composition pattern: pipelines are jobs
|
|
6
|
+
chained by a driver, not a new execution mode — a dead worker inside an
|
|
7
|
+
iteration costs one shard retry, and a dead *driver* can resume from the
|
|
8
|
+
last completed iteration's artifacts.
|
|
9
|
+
|
|
10
|
+
The same broadcast → partial-sums → reduce shape is, conceptually, what
|
|
11
|
+
gradient synchronization does with gradients instead of cluster sums.
|
|
12
|
+
|
|
13
|
+
Pure stdlib (urllib); usable as a library (`run_kmeans`) or CLI.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import random
|
|
20
|
+
import time
|
|
21
|
+
import urllib.request
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def init_centroids(rows: list[list[float]], k: int, seed: int = 0) -> list[list[float]]:
|
|
25
|
+
"""Deterministic sample of k distinct starting points."""
|
|
26
|
+
rng = random.Random(seed)
|
|
27
|
+
picks = rng.sample(range(len(rows)), k)
|
|
28
|
+
return [list(rows[i]) for i in sorted(picks)]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def reduce_partials(
|
|
32
|
+
partials: list[dict], prev_centroids: list[list[float]]
|
|
33
|
+
) -> tuple[list[list[float]], float]:
|
|
34
|
+
"""Sum the shard partials; new centroid = sums/counts. An empty cluster
|
|
35
|
+
keeps its previous centroid (the standard degenerate-cluster rule).
|
|
36
|
+
Returns (new_centroids, total_inertia)."""
|
|
37
|
+
k = len(prev_centroids)
|
|
38
|
+
dims = len(prev_centroids[0])
|
|
39
|
+
sums = [[0.0] * dims for _ in range(k)]
|
|
40
|
+
counts = [0] * k
|
|
41
|
+
inertia = 0.0
|
|
42
|
+
for p in partials:
|
|
43
|
+
for j in range(k):
|
|
44
|
+
counts[j] += p["counts"][j]
|
|
45
|
+
for d in range(dims):
|
|
46
|
+
sums[j][d] += p["sums"][j][d]
|
|
47
|
+
inertia += p["inertia"]
|
|
48
|
+
centroids = [
|
|
49
|
+
[s / counts[j] for s in sums[j]] if counts[j] else list(prev_centroids[j])
|
|
50
|
+
for j in range(k)
|
|
51
|
+
]
|
|
52
|
+
return centroids, inertia
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Coordinator plumbing (stdlib HTTP)
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _http(method: str, url: str, data: bytes | None = None, ctype="application/json"):
|
|
61
|
+
req = urllib.request.Request(
|
|
62
|
+
url, data=data, method=method, headers={"Content-Type": ctype} if data else {}
|
|
63
|
+
)
|
|
64
|
+
with urllib.request.urlopen(req, timeout=15) as r:
|
|
65
|
+
body = r.read()
|
|
66
|
+
return json.loads(body) if body else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def shard_and_upload(
|
|
70
|
+
base_url: str, rows: list[list[float]], n_shards: int, prefix: str
|
|
71
|
+
) -> list[str]:
|
|
72
|
+
"""Round-robin the rows into shards, upload each as a CSV artifact to
|
|
73
|
+
the coordinator; returns the artifact:// URIs."""
|
|
74
|
+
uris = []
|
|
75
|
+
for s in range(n_shards):
|
|
76
|
+
shard_rows = rows[s::n_shards]
|
|
77
|
+
csv_bytes = ("\n".join(",".join(f"{v:.6f}" for v in r) for r in shard_rows) + "\n").encode()
|
|
78
|
+
key = f"{prefix}/shard-{s:03d}.csv"
|
|
79
|
+
_http("PUT", f"{base_url}/v1alpha1/artifacts/{key}", csv_bytes,
|
|
80
|
+
ctype="application/octet-stream")
|
|
81
|
+
uris.append(f"artifact://{key}")
|
|
82
|
+
return uris
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def run_kmeans(
|
|
86
|
+
base_url: str,
|
|
87
|
+
shard_uris: list[str],
|
|
88
|
+
centroids: list[list[float]],
|
|
89
|
+
iterations: int = 5,
|
|
90
|
+
lease_seconds: float = 30.0,
|
|
91
|
+
poll_seconds: float = 0.5,
|
|
92
|
+
timeout_per_iteration_s: float = 300.0,
|
|
93
|
+
) -> dict:
|
|
94
|
+
"""Run Lloyd's algorithm as a sequence of lease jobs. Returns
|
|
95
|
+
{centroids, inertia_history, job_ids}."""
|
|
96
|
+
history: list[float] = []
|
|
97
|
+
job_ids: list[str] = []
|
|
98
|
+
for it in range(iterations):
|
|
99
|
+
job = _http("POST", f"{base_url}/v1alpha1/jobs", json.dumps({
|
|
100
|
+
"apiVersion": "flashml.dev/v1alpha1", "kind": "Job",
|
|
101
|
+
"metadata": {"name": f"kmeans-it{it:02d}"},
|
|
102
|
+
"spec": {
|
|
103
|
+
"execution": {"backend": "leases"},
|
|
104
|
+
"image": {"repository": "local/tier1", "tag": "dev"},
|
|
105
|
+
"workload": {"type": "sharded_kmeans", "parameters": {
|
|
106
|
+
"shards": shard_uris, "centroids": centroids,
|
|
107
|
+
"iteration": it, "lease_seconds": lease_seconds}},
|
|
108
|
+
}}).encode())
|
|
109
|
+
job_id = job["job_id"]
|
|
110
|
+
job_ids.append(job_id)
|
|
111
|
+
|
|
112
|
+
deadline = time.monotonic() + timeout_per_iteration_s
|
|
113
|
+
while True:
|
|
114
|
+
state = _http("GET", f"{base_url}/v1alpha1/jobs/{job_id}")["state"]
|
|
115
|
+
if state == "SUCCEEDED":
|
|
116
|
+
break
|
|
117
|
+
if state in ("FAILED", "CANCELLED"):
|
|
118
|
+
raise RuntimeError(f"kmeans iteration {it} ended {state} (job {job_id})")
|
|
119
|
+
if time.monotonic() > deadline:
|
|
120
|
+
raise TimeoutError(f"kmeans iteration {it} timed out (job {job_id})")
|
|
121
|
+
time.sleep(poll_seconds)
|
|
122
|
+
|
|
123
|
+
partials = []
|
|
124
|
+
for a in _http("GET", f"{base_url}/v1alpha1/jobs/{job_id}/artifacts"):
|
|
125
|
+
if a["key"].endswith("metrics.json"):
|
|
126
|
+
partials.append(_http("GET", f"{base_url}/v1alpha1/artifacts/{a['key']}"))
|
|
127
|
+
if len(partials) != len(shard_uris):
|
|
128
|
+
raise RuntimeError(
|
|
129
|
+
f"iteration {it}: expected {len(shard_uris)} partials, got {len(partials)}"
|
|
130
|
+
)
|
|
131
|
+
centroids, inertia = reduce_partials(partials, centroids)
|
|
132
|
+
history.append(inertia)
|
|
133
|
+
|
|
134
|
+
return {"centroids": centroids, "inertia_history": history, "job_ids": job_ids}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""One K-means map step over one data shard, runnable as a leased task.
|
|
2
|
+
|
|
3
|
+
Executor contract (same as sklearn_trial):
|
|
4
|
+
|
|
5
|
+
python -m flashml_workloads.kmeans_shard --spec spec.json --out OUTDIR
|
|
6
|
+
|
|
7
|
+
`spec.json`: {"params": {"centroids": [[…], …]}, "inputs": {"shard": path}}
|
|
8
|
+
The shard is a headerless CSV of points. Output `metrics.json` carries the
|
|
9
|
+
*partial* statistics — per-centroid coordinate sums, counts, and inertia —
|
|
10
|
+
which the driver reduces into the next round's centroids. Pure stdlib:
|
|
11
|
+
assignment is just nearest-squared-distance; no numpy required on devices.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import csv
|
|
18
|
+
import json
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_shard(spec: dict) -> dict:
|
|
23
|
+
centroids: list[list[float]] = spec["params"]["centroids"]
|
|
24
|
+
k = len(centroids)
|
|
25
|
+
dims = len(centroids[0])
|
|
26
|
+
sums = [[0.0] * dims for _ in range(k)]
|
|
27
|
+
counts = [0] * k
|
|
28
|
+
inertia = 0.0
|
|
29
|
+
n = 0
|
|
30
|
+
|
|
31
|
+
with open(spec["inputs"]["shard"], newline="") as f:
|
|
32
|
+
for row in csv.reader(f):
|
|
33
|
+
if not row:
|
|
34
|
+
continue
|
|
35
|
+
point = [float(x) for x in row]
|
|
36
|
+
best, best_d = 0, float("inf")
|
|
37
|
+
for j, c in enumerate(centroids):
|
|
38
|
+
d = sum((p - q) ** 2 for p, q in zip(point, c))
|
|
39
|
+
if d < best_d:
|
|
40
|
+
best, best_d = j, d
|
|
41
|
+
for dim in range(dims):
|
|
42
|
+
sums[best][dim] += point[dim]
|
|
43
|
+
counts[best] += 1
|
|
44
|
+
inertia += best_d
|
|
45
|
+
n += 1
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
"task_id": spec.get("task_id", ""),
|
|
49
|
+
"sums": [[round(v, 10) for v in s] for s in sums],
|
|
50
|
+
"counts": counts,
|
|
51
|
+
"inertia": inertia,
|
|
52
|
+
"n": n,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main(argv: list[str] | None = None) -> int:
|
|
57
|
+
parser = argparse.ArgumentParser(prog="kmeans_shard")
|
|
58
|
+
parser.add_argument("--spec", required=True)
|
|
59
|
+
parser.add_argument("--out", required=True)
|
|
60
|
+
args = parser.parse_args(argv)
|
|
61
|
+
spec = json.loads(Path(args.spec).read_text())
|
|
62
|
+
out = Path(args.out)
|
|
63
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
(out / "metrics.json").write_text(json.dumps(run_shard(spec), sort_keys=True))
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
raise SystemExit(main())
|