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,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())