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,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}")
|