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,597 @@
1
+ """Candidate generation and evaluation.
2
+
3
+ The planner does not search an open space — it walks a *curated menu* of
4
+ strategy families per workload class (ADR-0003) and evaluates each candidate
5
+ with the static estimators:
6
+
7
+ transformer/pytorch training : single_gpu · ddp · fsdp2 · zero3_cpu_offload
8
+ (+ QLoRA variants when quantization is allowed)
9
+ classical ML : local_process · sharded_partial_fit (Mode A)
10
+ independent tasks : lease_tasks (Mode A) · local_sequential
11
+
12
+ Evaluation per candidate: memory fit (with the safety bands), host-RAM fit
13
+ for offload, communication efficiency for the topology, then time/cost and
14
+ the user's hard constraints. Every elimination keeps its arithmetic — the
15
+ rejections are half the product.
16
+
17
+ When `activation_checkpointing` is unset, the planner tries without first
18
+ and enables it only if that is what makes the candidate fit (recompute costs
19
+ ~+30% step time, so it is never on by default).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass, field
25
+
26
+ from flashruntime.planner import comm, memory, timecost
27
+ from flashruntime.planner.catalog import AUTO_OK_FRACTION, HARD_LIMIT_FRACTION
28
+ from flashruntime.planner.memory import ShardingConfig
29
+ from flashruntime.planner.resolve import (
30
+ ResolvedGPU,
31
+ ResolvedTransformer,
32
+ resolve_gpu,
33
+ resolve_transformer,
34
+ )
35
+ from flashruntime.protocol.plan_v1alpha1 import (
36
+ CandidateVerdict,
37
+ ClassicalML,
38
+ Estimate,
39
+ IndependentTasks,
40
+ LibraryRef,
41
+ MemoryBreakdown,
42
+ PlanRequest,
43
+ PyTorchTraining,
44
+ TransformerFineTune,
45
+ )
46
+
47
+ _PRECISION_BYTES = {"bf16": 2.0, "fp16": 2.0, "fp32": 4.0}
48
+
49
+
50
+ @dataclass
51
+ class Evaluated:
52
+ """One evaluated candidate: its public verdict plus everything the
53
+ selector needs to rank it and to mint a StrategyPlan from the winner."""
54
+
55
+ verdict: CandidateVerdict
56
+ workload_mode: str = "coordinated_training"
57
+ launcher: str = "torchrun"
58
+ gpus_per_worker: int = 1
59
+ colocated: bool = True
60
+ precision: str | None = None
61
+ quantization: str | None = None
62
+ peft: str | None = None
63
+ micro_batch: int | None = None
64
+ grad_accum: int | None = None
65
+ act_ckpt: bool | None = None
66
+ offload: str = "none"
67
+ libraries: list[LibraryRef] = field(default_factory=list)
68
+ headroom_gb: float = 0.0
69
+ vram_deficit_gb: float = 0.0 # >0 when memory-infeasible: how far off it was
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Library stacks — the "which libraries, in which roles" answer.
74
+ # ---------------------------------------------------------------------------
75
+
76
+
77
+ def _training_stack(
78
+ family: str, world: int, method: str, quantized: bool
79
+ ) -> list[LibraryRef]:
80
+ libs: list[LibraryRef] = [LibraryRef(name="pytorch", role="framework", purpose="training loop, autograd")]
81
+ if family == "ddp" and world > 1:
82
+ libs.append(LibraryRef(name="torch DDP", role="strategy", purpose="per-step gradient all-reduce"))
83
+ elif family == "fsdp2":
84
+ libs.append(
85
+ LibraryRef(
86
+ name="torch FSDP2 (fully_shard)",
87
+ role="strategy",
88
+ purpose="parameter/gradient/optimizer sharding on DTensor",
89
+ )
90
+ )
91
+ elif family == "zero3_cpu_offload":
92
+ libs.append(
93
+ LibraryRef(
94
+ name="DeepSpeed ZeRO-3 + CPU offload",
95
+ role="strategy",
96
+ purpose="full sharding with optimizer state in host RAM",
97
+ )
98
+ )
99
+ if method in ("lora", "qlora") or quantized:
100
+ libs.append(LibraryRef(name="transformers", role="workload", purpose="model + tokenizer + Trainer recipe"))
101
+ libs.append(LibraryRef(name="peft", role="workload", purpose="LoRA adapter injection"))
102
+ if quantized:
103
+ libs.append(LibraryRef(name="bitsandbytes", role="workload", purpose="NF4 4-bit frozen-weight quantization"))
104
+ libs.append(
105
+ LibraryRef(
106
+ name="torchrun" if world > 1 else "local process",
107
+ role="launcher",
108
+ purpose="worker-group launch + restart on membership change" if world > 1 else "single-process launch",
109
+ )
110
+ )
111
+ libs.append(
112
+ LibraryRef(name="pytorch DCP", role="checkpoint", purpose="parallel save/load with resharding on restore")
113
+ )
114
+ libs.append(
115
+ LibraryRef(
116
+ name="flashruntime",
117
+ role="runtime",
118
+ purpose="event ledger, checkpoint catalog, failure taxonomy, recovery",
119
+ )
120
+ )
121
+ return libs
122
+
123
+
124
+ def _lease_stack(needs_docker: bool = True) -> list[LibraryRef]:
125
+ libs = [
126
+ LibraryRef(
127
+ name="flashruntime leases",
128
+ role="launcher",
129
+ purpose="pull-based task leases: claim → heartbeat → idempotent commit",
130
+ )
131
+ ]
132
+ if needs_docker:
133
+ libs.append(LibraryRef(name="docker (via flashnode)", role="executor", purpose="sandboxed task execution on devices"))
134
+ libs.append(LibraryRef(name="flashruntime", role="runtime", purpose="event ledger, retries, artifact commit"))
135
+ return libs
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Transformer fine-tuning (and generic PyTorch via the same skeleton)
140
+ # ---------------------------------------------------------------------------
141
+
142
+
143
+ def _world_sizes(gpus: int) -> list[int]:
144
+ """1, powers of two, and the full count — smallest first (the planner
145
+ prefers the smallest world size that meets the deadline)."""
146
+ sizes = {1}
147
+ n = 2
148
+ while n < gpus:
149
+ sizes.add(n)
150
+ n *= 2
151
+ if gpus >= 1:
152
+ sizes.add(gpus)
153
+ return sorted(sizes)
154
+
155
+
156
+ _FAMILY_SHARDING = {
157
+ "single_gpu": ShardingConfig(),
158
+ "ddp": ShardingConfig(),
159
+ "fsdp2": ShardingConfig(shard_weights=True, shard_gradients=True, shard_optimizer=True),
160
+ "zero3_cpu_offload": ShardingConfig(
161
+ shard_weights=True, shard_gradients=True, shard_optimizer=True, offload_optimizer=True
162
+ ),
163
+ }
164
+
165
+
166
+ def transformer_candidates(req: PlanRequest) -> tuple[list[Evaluated], list[str]]:
167
+ w = req.workload
168
+ assert isinstance(w, TransformerFineTune)
169
+ rt = resolve_transformer(w)
170
+ gpu = resolve_gpu(req.resources)
171
+ notes = rt.notes + gpu.notes
172
+
173
+ if req.resources.gpus < 1:
174
+ return [], notes + ["transformer fine-tuning requires at least one GPU in v1"]
175
+
176
+ variants: list[tuple[str, TransformerFineTune, ResolvedTransformer]] = [("", w, rt)]
177
+ if w.method == "lora" and req.objective.allow_quantization:
178
+ qw = w.model_copy(update={"method": "qlora"})
179
+ variants.append(("qlora_", qw, resolve_transformer(qw)))
180
+
181
+ out: list[Evaluated] = []
182
+ for prefix, wv, rtv in variants:
183
+ for world in _world_sizes(req.resources.gpus):
184
+ families = ["single_gpu"] if world == 1 else ["ddp", "fsdp2", "zero3_cpu_offload"]
185
+ for family in families:
186
+ if family == "zero3_cpu_offload" and not req.objective.allow_cpu_offload:
187
+ continue
188
+ if family == "fsdp2" and wv.method == "qlora":
189
+ continue # quantized frozen weights + full param sharding: unsupported combo in v1
190
+ out.append(_evaluate_training(req, wv, rtv, gpu, f"{prefix}{family}", family, world))
191
+ return out, notes
192
+
193
+
194
+ def _evaluate_training(
195
+ req: PlanRequest,
196
+ w: TransformerFineTune,
197
+ rt: ResolvedTransformer,
198
+ gpu: ResolvedGPU,
199
+ name: str,
200
+ family: str,
201
+ world: int,
202
+ ) -> Evaluated:
203
+ reasons: list[str] = []
204
+ base_sharding = _FAMILY_SHARDING[family]
205
+ sharding = ShardingConfig(
206
+ world=world,
207
+ shard_weights=base_sharding.shard_weights,
208
+ shard_gradients=base_sharding.shard_gradients,
209
+ shard_optimizer=base_sharding.shard_optimizer,
210
+ offload_optimizer=base_sharding.offload_optimizer,
211
+ )
212
+
213
+ # -- memory, with auto activation-checkpointing -------------------------
214
+ act = w.activation_checkpointing
215
+ mem = memory.transformer_memory(w, rt, sharding, activation_checkpointing=bool(act))
216
+ if act is None and mem.total_gb > gpu.vram_gb * HARD_LIMIT_FRACTION:
217
+ retry = memory.transformer_memory(w, rt, sharding, activation_checkpointing=True)
218
+ if retry.total_gb <= gpu.vram_gb * HARD_LIMIT_FRACTION:
219
+ mem, act = retry, True
220
+ name = f"{name}+ackpt"
221
+ reasons.append("activation checkpointing enabled to fit (recompute ≈ +30% step time)")
222
+ else:
223
+ act = False
224
+ act = bool(act)
225
+
226
+ hard_cap = gpu.vram_gb * HARD_LIMIT_FRACTION
227
+ auto_cap = gpu.vram_gb * AUTO_OK_FRACTION
228
+ deficit = max(0.0, mem.total_gb - hard_cap)
229
+ if deficit > 0:
230
+ reasons.append(
231
+ f"estimated peak {mem.total_gb} GB/GPU exceeds {gpu.vram_gb} GB × "
232
+ f"{HARD_LIMIT_FRACTION} = {hard_cap:.1f} GB — infeasible"
233
+ )
234
+ return _reject(name, family, world, mem, reasons, deficit=deficit)
235
+ profiling_required = mem.total_gb > auto_cap
236
+ reasons.append(
237
+ f"estimated peak {mem.total_gb} GB/GPU of {gpu.vram_gb} GB "
238
+ f"({'requires profiling before launch' if profiling_required else 'fits with static margin'})"
239
+ )
240
+
241
+ ram_budget = req.resources.cpu_ram_gb * 0.8
242
+ if mem.cpu_offload_gb > ram_budget:
243
+ reasons.append(
244
+ f"offloaded optimizer state {mem.cpu_offload_gb} GB exceeds 80% of host RAM "
245
+ f"({req.resources.cpu_ram_gb} GB) — infeasible"
246
+ )
247
+ return _reject(name, family, world, mem, reasons)
248
+
249
+ # -- communication ------------------------------------------------------
250
+ interconnect = _effective_interconnect(req, world)
251
+ t_micro = timecost.compute_time_per_micro_step_s(
252
+ rt.params, w.seq_len, w.micro_batch_per_gpu, gpu.bf16_tflops
253
+ )
254
+ prec_bytes = _PRECISION_BYTES[w.precision]
255
+ if family == "ddp" or family == "single_gpu":
256
+ cv = comm.ddp_efficiency(
257
+ rt.trainable_params * prec_bytes, world, interconnect, t_micro * w.grad_accum
258
+ )
259
+ else:
260
+ cv = comm.fsdp_efficiency(rt.params * prec_bytes, rt.layers, world, interconnect, t_micro)
261
+ reasons.append(cv.note)
262
+ if not cv.feasible:
263
+ return _reject(name, family, world, mem, reasons, efficiency=cv.efficiency)
264
+
265
+ # -- time, cost, hard constraints ---------------------------------------
266
+ quantized = w.method == "qlora"
267
+ offload = family == "zero3_cpu_offload"
268
+ t = timecost.training_time_minutes(
269
+ rt.params,
270
+ (w.train_tokens_m or 0) * 1e6,
271
+ world,
272
+ gpu.bf16_tflops,
273
+ cv.efficiency,
274
+ activation_checkpointing=act,
275
+ qlora=quantized,
276
+ cpu_offload=offload,
277
+ )
278
+ c = timecost.cost_usd(t, world, req.resources.hourly_cost_usd_per_gpu)
279
+
280
+ status = "feasible"
281
+ if t is not None and req.objective.deadline_minutes and t.value > req.objective.deadline_minutes:
282
+ status = "rejected_policy"
283
+ reasons.append(
284
+ f"estimated {t.value:.0f} min misses the {req.objective.deadline_minutes:.0f} min deadline"
285
+ )
286
+ if c is not None and req.objective.max_cost_usd and c.value > req.objective.max_cost_usd:
287
+ status = "rejected_policy"
288
+ reasons.append(f"estimated ${c.value:.2f} exceeds budget ${req.objective.max_cost_usd:.2f}")
289
+ if t is not None and status == "feasible":
290
+ reasons.append(f"estimated {t.value:.0f} min on {world} GPU(s)" + (f", ${c.value:.2f}" if c else ""))
291
+
292
+ verdict = CandidateVerdict(
293
+ name=name,
294
+ strategy_family=family,
295
+ workers=world,
296
+ status=status,
297
+ memory=mem,
298
+ est_time_min=t,
299
+ est_cost_usd=c,
300
+ scaling_efficiency=cv.efficiency,
301
+ profiling_required=profiling_required,
302
+ reasons=reasons,
303
+ )
304
+ return Evaluated(
305
+ verdict=verdict,
306
+ workload_mode="local" if world == 1 else "coordinated_training",
307
+ launcher="torchrun" if world > 1 else "local",
308
+ colocated=world <= max(1, req.resources.gpus // req.resources.hosts),
309
+ precision=w.precision,
310
+ quantization="nf4" if quantized else None,
311
+ peft=f"lora(r={w.lora_rank})" if w.method in ("lora", "qlora") else None,
312
+ micro_batch=w.micro_batch_per_gpu,
313
+ grad_accum=w.grad_accum,
314
+ act_ckpt=act,
315
+ offload="optimizer_cpu" if offload else "none",
316
+ libraries=_training_stack(family, world, w.method, quantized),
317
+ headroom_gb=round(gpu.vram_gb - mem.total_gb, 2),
318
+ )
319
+
320
+
321
+ def _effective_interconnect(req: PlanRequest, world: int) -> str:
322
+ """A worker group that fits on one host uses that host's local link;
323
+ only groups spanning hosts pay the cluster interconnect."""
324
+ gpus_per_host = max(1, req.resources.gpus // req.resources.hosts)
325
+ if req.resources.hosts == 1 or world <= gpus_per_host:
326
+ return (
327
+ req.resources.interconnect
328
+ if req.resources.interconnect.startswith("same_host")
329
+ else "same_host_pcie"
330
+ )
331
+ return req.resources.interconnect
332
+
333
+
334
+ def _reject(
335
+ name: str,
336
+ family: str,
337
+ world: int,
338
+ mem: MemoryBreakdown,
339
+ reasons: list[str],
340
+ *,
341
+ deficit: float = 0.0,
342
+ efficiency: float | None = None,
343
+ ) -> Evaluated:
344
+ return Evaluated(
345
+ verdict=CandidateVerdict(
346
+ name=name,
347
+ strategy_family=family,
348
+ workers=world,
349
+ status="infeasible",
350
+ memory=mem,
351
+ scaling_efficiency=efficiency,
352
+ reasons=reasons,
353
+ ),
354
+ vram_deficit_gb=round(deficit, 2),
355
+ )
356
+
357
+
358
+ # ---------------------------------------------------------------------------
359
+ # Generic PyTorch training — same skeleton, user-supplied activation memory
360
+ # ---------------------------------------------------------------------------
361
+
362
+ _DEFAULT_ACTIVATION_GB = 2.0 # pessimistic default when unspecified [assumption]
363
+
364
+
365
+ def pytorch_candidates(req: PlanRequest) -> tuple[list[Evaluated], list[str]]:
366
+ w = req.workload
367
+ assert isinstance(w, PyTorchTraining)
368
+ notes: list[str] = []
369
+ if req.resources.gpus < 1:
370
+ return [], ["pytorch_training requires at least one GPU in v1 (CPU training: use classical_ml or independent_tasks)"]
371
+ gpu = resolve_gpu(req.resources)
372
+ notes.extend(gpu.notes)
373
+ act_gb = w.activation_gb_per_gpu or _DEFAULT_ACTIVATION_GB
374
+ if w.activation_gb_per_gpu is None:
375
+ notes.append(f"activation memory defaulted to {_DEFAULT_ACTIVATION_GB} GB/GPU [assumption]")
376
+
377
+ out: list[Evaluated] = []
378
+ for world in _world_sizes(req.resources.gpus):
379
+ families = ["single_gpu"] if world == 1 else ["ddp", "fsdp2"]
380
+ for family in families:
381
+ base = _FAMILY_SHARDING[family]
382
+ sharding = ShardingConfig(
383
+ world=world,
384
+ shard_weights=base.shard_weights,
385
+ shard_gradients=base.shard_gradients,
386
+ shard_optimizer=base.shard_optimizer,
387
+ )
388
+ mem = memory.generic_training_memory(
389
+ w.parameters_m, w.trainable_fraction, w.precision, w.optimizer, act_gb, sharding
390
+ )
391
+ reasons: list[str] = []
392
+ gpu_cap = gpu.vram_gb * HARD_LIMIT_FRACTION
393
+ if mem.total_gb > gpu_cap:
394
+ reasons.append(f"estimated peak {mem.total_gb} GB/GPU exceeds {gpu_cap:.1f} GB cap — infeasible")
395
+ out.append(_reject(family, family, world, mem, reasons, deficit=mem.total_gb - gpu_cap))
396
+ continue
397
+ profiling = mem.total_gb > gpu.vram_gb * AUTO_OK_FRACTION
398
+ reasons.append(f"estimated peak {mem.total_gb} GB/GPU of {gpu.vram_gb} GB")
399
+
400
+ interconnect = _effective_interconnect(req, world)
401
+ prec = _PRECISION_BYTES[w.precision]
402
+ grad_bytes = w.parameters_m * 1e6 * w.trainable_fraction * prec
403
+ # No FLOPs identity for arbitrary models — comm verdict falls back
404
+ # to the link-class gate when compute time is unknown.
405
+ cv = (
406
+ comm.ddp_efficiency(grad_bytes, world, interconnect, 0.0)
407
+ if family in ("ddp", "single_gpu")
408
+ else comm.fsdp_efficiency(w.parameters_m * 1e6 * prec, 32, world, interconnect, 0.0)
409
+ )
410
+ reasons.append(cv.note)
411
+ if not cv.feasible:
412
+ out.append(_reject(family, family, world, mem, reasons, efficiency=cv.efficiency))
413
+ continue
414
+
415
+ t = None
416
+ if w.est_train_gpu_hours:
417
+ minutes = w.est_train_gpu_hours * 60.0 / (world * max(0.01, cv.efficiency))
418
+ t = Estimate(value=round(minutes, 1), unit="min", basis="static", note="scaled from user single-GPU estimate")
419
+ c = timecost.cost_usd(t, world, req.resources.hourly_cost_usd_per_gpu)
420
+ status = "feasible"
421
+ if t and req.objective.deadline_minutes and t.value > req.objective.deadline_minutes:
422
+ status = "rejected_policy"
423
+ reasons.append(f"estimated {t.value:.0f} min misses the deadline")
424
+ if c and req.objective.max_cost_usd and c.value > req.objective.max_cost_usd:
425
+ status = "rejected_policy"
426
+ reasons.append(f"estimated ${c.value:.2f} exceeds budget")
427
+
428
+ out.append(
429
+ Evaluated(
430
+ verdict=CandidateVerdict(
431
+ name=family if world == 1 else f"{family}_x{world}",
432
+ strategy_family=family,
433
+ workers=world,
434
+ status=status,
435
+ memory=mem,
436
+ est_time_min=t,
437
+ est_cost_usd=c,
438
+ scaling_efficiency=cv.efficiency,
439
+ profiling_required=profiling,
440
+ reasons=reasons,
441
+ ),
442
+ workload_mode="local" if world == 1 else "coordinated_training",
443
+ launcher="torchrun" if world > 1 else "local",
444
+ precision=w.precision,
445
+ libraries=_training_stack(family, world, "full", False),
446
+ headroom_gb=round(gpu.vram_gb - mem.total_gb, 2),
447
+ )
448
+ )
449
+ return out, notes
450
+
451
+
452
+ # ---------------------------------------------------------------------------
453
+ # Classical ML and independent tasks (Mode 0 / Mode A)
454
+ # ---------------------------------------------------------------------------
455
+
456
+
457
+ def classical_candidates(req: PlanRequest) -> tuple[list[Evaluated], list[str]]:
458
+ w = req.workload
459
+ assert isinstance(w, ClassicalML)
460
+ out: list[Evaluated] = []
461
+ # Working-set rule of thumb: sklearn copies cost ~3× the dataset. [assumption]
462
+ fits_ram = w.dataset_mb * 3 / 1000.0 <= req.resources.cpu_ram_gb * 0.8
463
+ reasons = [
464
+ f"dataset {w.dataset_mb:.0f} MB × 3 working-set ≈ {w.dataset_mb * 3 / 1000:.1f} GB vs "
465
+ f"{req.resources.cpu_ram_gb} GB host RAM"
466
+ ]
467
+ out.append(
468
+ Evaluated(
469
+ verdict=CandidateVerdict(
470
+ name="local_process",
471
+ strategy_family="local_process",
472
+ workers=1,
473
+ status="feasible" if fits_ram else "infeasible",
474
+ reasons=reasons + ([] if fits_ram else ["dataset does not fit in host RAM"]),
475
+ ),
476
+ workload_mode="local",
477
+ launcher="local",
478
+ gpus_per_worker=0,
479
+ libraries=[
480
+ LibraryRef(name=w.library, role="workload", purpose=f"{w.algorithm} fit/predict"),
481
+ LibraryRef(name="flashruntime", role="runtime", purpose="job record + artifacts"),
482
+ ],
483
+ )
484
+ )
485
+ if w.supports_partial_fit:
486
+ shards = max(2, min(16, int(w.dataset_mb // 500) or 2))
487
+ out.append(
488
+ Evaluated(
489
+ verdict=CandidateVerdict(
490
+ name="sharded_partial_fit",
491
+ strategy_family="sharded_partial_fit",
492
+ workers=shards,
493
+ status="feasible",
494
+ reasons=[
495
+ f"{shards} data shards trained incrementally via leased tasks, "
496
+ "periodic reduce (the sharded K-means pattern)"
497
+ ],
498
+ ),
499
+ workload_mode="independent_tasks",
500
+ launcher="flashruntime-leases",
501
+ gpus_per_worker=0,
502
+ libraries=_lease_stack() + [LibraryRef(name=w.library, role="workload", purpose="partial_fit per shard")],
503
+ )
504
+ )
505
+ return out, []
506
+
507
+
508
+ def tasks_candidates(req: PlanRequest) -> tuple[list[Evaluated], list[str]]:
509
+ w = req.workload
510
+ assert isinstance(w, IndependentTasks)
511
+ r = req.resources
512
+ notes: list[str] = []
513
+
514
+ if w.needs_gpu:
515
+ slots = r.gpus
516
+ gpu_note = f"{r.gpus} GPU slot(s)"
517
+ if r.gpus and w.vram_gb_per_task:
518
+ gpu = resolve_gpu(r)
519
+ if w.vram_gb_per_task > gpu.vram_gb * HARD_LIMIT_FRACTION:
520
+ return [
521
+ _reject(
522
+ "lease_tasks",
523
+ "lease_tasks",
524
+ 0,
525
+ MemoryBreakdown(total_gb=w.vram_gb_per_task),
526
+ [f"each task needs {w.vram_gb_per_task} GB VRAM; GPUs have {gpu.vram_gb} GB"],
527
+ deficit=w.vram_gb_per_task - gpu.vram_gb,
528
+ )
529
+ ], notes
530
+ else:
531
+ slots = r.hosts * max(1, r.cpu_cores // 4) # ~4 cores per task [assumption]
532
+ gpu_note = f"{r.hosts} host(s) × {max(1, r.cpu_cores // 4)} concurrent task(s)"
533
+ slots = max(1, min(slots, w.task_count))
534
+
535
+ waves = -(-w.task_count // slots) # ceil division
536
+ par_min = waves * w.est_minutes_per_task
537
+ seq_min = w.task_count * w.est_minutes_per_task
538
+
539
+ out = [
540
+ Evaluated(
541
+ verdict=CandidateVerdict(
542
+ name="lease_tasks",
543
+ strategy_family="lease_tasks",
544
+ workers=slots,
545
+ status="feasible",
546
+ est_time_min=Estimate(
547
+ value=round(par_min, 1),
548
+ unit="min",
549
+ basis="static",
550
+ note=f"{w.task_count} tasks / {slots} slots = {waves} wave(s)",
551
+ ),
552
+ reasons=[
553
+ f"{w.task_count} independent {w.task_kind} tasks over {gpu_note}",
554
+ "per-task failure isolation: a lost node costs one task retry, never the job",
555
+ ],
556
+ ),
557
+ workload_mode="independent_tasks",
558
+ launcher="flashruntime-leases",
559
+ gpus_per_worker=1 if w.needs_gpu else 0,
560
+ colocated=False,
561
+ libraries=_lease_stack(),
562
+ ),
563
+ Evaluated(
564
+ verdict=CandidateVerdict(
565
+ name="local_sequential",
566
+ strategy_family="local_process",
567
+ workers=1,
568
+ status="feasible" if slots == 1 else "rejected_dominated",
569
+ est_time_min=Estimate(value=round(seq_min, 1), unit="min", basis="static"),
570
+ reasons=[f"baseline: {w.task_count} tasks one after another ≈ {seq_min:.0f} min"],
571
+ ),
572
+ workload_mode="local",
573
+ launcher="local",
574
+ gpus_per_worker=1 if w.needs_gpu else 0,
575
+ libraries=[LibraryRef(name="flashruntime", role="runtime", purpose="job record + artifacts")],
576
+ ),
577
+ ]
578
+ if req.objective.deadline_minutes and par_min > req.objective.deadline_minutes:
579
+ out[0].verdict.status = "rejected_policy"
580
+ out[0].verdict.reasons.append(
581
+ f"even at {slots}-wide parallelism, {par_min:.0f} min misses the deadline"
582
+ )
583
+ return out, notes
584
+
585
+
586
+ def generate(req: PlanRequest) -> tuple[list[Evaluated], list[str]]:
587
+ """Dispatch on workload kind → (evaluated candidates, resolution notes)."""
588
+ kind = req.workload.kind
589
+ if kind == "transformer_finetune":
590
+ return transformer_candidates(req)
591
+ if kind == "pytorch_training":
592
+ return pytorch_candidates(req)
593
+ if kind == "classical_ml":
594
+ return classical_candidates(req)
595
+ if kind == "independent_tasks":
596
+ return tasks_candidates(req)
597
+ raise ValueError(f"unsupported workload kind: {kind}")