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,129 @@
|
|
|
1
|
+
"""Reference data the estimators consult: known models, GPU classes, links.
|
|
2
|
+
|
|
3
|
+
Everything here is an *approximation with a source-of-truth elsewhere* —
|
|
4
|
+
model cards, vendor datasheets, measured benchmarks. Values are deliberately
|
|
5
|
+
conservative; the ledger (measured runs) is meant to override them over
|
|
6
|
+
time. Adding an entry is the supported way to extend planner coverage — no
|
|
7
|
+
code changes required.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ModelInfo:
|
|
17
|
+
"""Structural facts about a known transformer, for estimation only."""
|
|
18
|
+
|
|
19
|
+
parameters_b: float
|
|
20
|
+
hidden_size: int
|
|
21
|
+
num_layers: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Keys are matched case-insensitively on the trailing path component, so
|
|
25
|
+
# "Qwen/Qwen2.5-7B", "qwen2.5-7b" and "Qwen2.5-7B-Instruct" all resolve.
|
|
26
|
+
# Parameter counts are the published totals (approximate).
|
|
27
|
+
MODEL_CATALOG: dict[str, ModelInfo] = {
|
|
28
|
+
"qwen2.5-0.5b": ModelInfo(0.49, 896, 24),
|
|
29
|
+
"qwen2.5-1.5b": ModelInfo(1.54, 1536, 28),
|
|
30
|
+
"qwen2.5-3b": ModelInfo(3.09, 2048, 36),
|
|
31
|
+
"qwen2.5-7b": ModelInfo(7.62, 3584, 28),
|
|
32
|
+
"qwen2.5-14b": ModelInfo(14.7, 5120, 48),
|
|
33
|
+
"qwen2.5-32b": ModelInfo(32.8, 5120, 64),
|
|
34
|
+
"qwen2.5-72b": ModelInfo(72.7, 8192, 80),
|
|
35
|
+
"llama-3.1-8b": ModelInfo(8.03, 4096, 32),
|
|
36
|
+
"llama-3.1-70b": ModelInfo(70.6, 8192, 80),
|
|
37
|
+
"llama-3.2-1b": ModelInfo(1.24, 2048, 16),
|
|
38
|
+
"llama-3.2-3b": ModelInfo(3.21, 3072, 28),
|
|
39
|
+
"mistral-7b": ModelInfo(7.25, 4096, 32),
|
|
40
|
+
"gemma-2-9b": ModelInfo(9.24, 3584, 42),
|
|
41
|
+
"gpt2": ModelInfo(0.124, 768, 12),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def lookup_model(name: str) -> ModelInfo | None:
|
|
46
|
+
"""Resolve a model name to catalog info; forgiving about org prefixes
|
|
47
|
+
and -Instruct/-Chat suffixes."""
|
|
48
|
+
tail = name.strip().lower().split("/")[-1]
|
|
49
|
+
for suffix in ("-instruct", "-chat", "-base", "-hf"):
|
|
50
|
+
tail = tail.removesuffix(suffix)
|
|
51
|
+
return MODEL_CATALOG.get(tail)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def derive_transformer_shape(parameters_b: float) -> tuple[int, int]:
|
|
55
|
+
"""Guess (hidden_size, num_layers) from parameter count when the user
|
|
56
|
+
gave neither. Dense-transformer typicals; the activation estimate built
|
|
57
|
+
on this is labeled as derived. [assumption]"""
|
|
58
|
+
table = [
|
|
59
|
+
(0.2, (768, 12)),
|
|
60
|
+
(0.7, (1024, 24)),
|
|
61
|
+
(2.0, (2048, 24)),
|
|
62
|
+
(4.0, (3072, 28)),
|
|
63
|
+
(9.0, (4096, 32)),
|
|
64
|
+
(16.0, (5120, 40)),
|
|
65
|
+
(40.0, (6656, 60)),
|
|
66
|
+
(90.0, (8192, 80)),
|
|
67
|
+
]
|
|
68
|
+
for ceiling, shape in table:
|
|
69
|
+
if parameters_b <= ceiling:
|
|
70
|
+
return shape
|
|
71
|
+
return (12288, 96)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class GPUInfo:
|
|
76
|
+
"""One GPU class. `bf16_tflops` is a *conservative dense* figure — never
|
|
77
|
+
the sparsity marketing number."""
|
|
78
|
+
|
|
79
|
+
vram_gb: float
|
|
80
|
+
bf16_tflops: float
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
GPU_CATALOG: dict[str, GPUInfo] = {
|
|
84
|
+
"h100": GPUInfo(80, 700),
|
|
85
|
+
"a100-80gb": GPUInfo(80, 312),
|
|
86
|
+
"a100-40gb": GPUInfo(40, 312),
|
|
87
|
+
"a100": GPUInfo(40, 312),
|
|
88
|
+
"l40s": GPUInfo(48, 362),
|
|
89
|
+
"rtx4090": GPUInfo(24, 165),
|
|
90
|
+
"rtx3090": GPUInfo(24, 71),
|
|
91
|
+
"a10": GPUInfo(24, 125),
|
|
92
|
+
"l4": GPUInfo(24, 121),
|
|
93
|
+
"v100": GPUInfo(16, 112),
|
|
94
|
+
"t4": GPUInfo(16, 65),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def lookup_gpu(gpu_type: str | None) -> GPUInfo | None:
|
|
99
|
+
if not gpu_type:
|
|
100
|
+
return None
|
|
101
|
+
return GPU_CATALOG.get(gpu_type.strip().lower())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Effective point-to-point bandwidth per interconnect class, GB/s, and a
|
|
105
|
+
# per-collective latency floor in seconds. Conservative effective figures,
|
|
106
|
+
# not line rates. [assumption — FlashNode's network benchmark replaces these
|
|
107
|
+
# with measurements per pool]
|
|
108
|
+
INTERCONNECT_GBPS: dict[str, tuple[float, float]] = {
|
|
109
|
+
"same_host_nvlink": (250.0, 0.00001),
|
|
110
|
+
"same_host_pcie": (20.0, 0.00002),
|
|
111
|
+
"multi_node_ib": (25.0, 0.00005),
|
|
112
|
+
"multi_node_100g": (10.0, 0.0001),
|
|
113
|
+
"multi_node_10g": (1.1, 0.0003),
|
|
114
|
+
"wan": (0.05, 0.03),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# Model-FLOPs-utilization assumed for static throughput estimates.
|
|
118
|
+
# Real MFU varies 0.2–0.5 by model/kernel/stack; 0.30 is a defensible
|
|
119
|
+
# middle. [assumption — profiling replaces this]
|
|
120
|
+
ASSUMED_MFU = 0.30
|
|
121
|
+
|
|
122
|
+
# Flat per-GPU overhead: CUDA context + NCCL buffers + framework workspace.
|
|
123
|
+
# [assumption]
|
|
124
|
+
FLAT_OVERHEAD_GB = 2.5
|
|
125
|
+
|
|
126
|
+
# Static-only safety bands (ADR-0003 / evaluation §D): auto-launch below
|
|
127
|
+
# AUTO_OK, require profiling in the band, infeasible above HARD_LIMIT.
|
|
128
|
+
AUTO_OK_FRACTION = 0.80
|
|
129
|
+
HARD_LIMIT_FRACTION = 0.95
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Communication feasibility: is the network good enough for this strategy?
|
|
2
|
+
|
|
3
|
+
First-order per-step traffic model (evaluation §E):
|
|
4
|
+
|
|
5
|
+
- DDP: one gradient all-reduce per optimizer step. Ring all-reduce moves
|
|
6
|
+
≈ 2·(N−1)/N · gradient_bytes per GPU. For PEFT the gradients are only the
|
|
7
|
+
adapters' — which is why LoRA+DDP tolerates weak links that full
|
|
8
|
+
fine-tuning cannot.
|
|
9
|
+
- FSDP2/ZeRO-3: per layer, all-gather params (forward), re-gather
|
|
10
|
+
(backward), reduce-scatter grads ⇒ ≈ 3× sharded-parameter bytes per step
|
|
11
|
+
in ~3·L latency-sensitive collectives.
|
|
12
|
+
|
|
13
|
+
Verdict = scaling efficiency E = t_compute / (t_compute + t_exposed_comm),
|
|
14
|
+
with half the compute assumed overlappable. E < REJECT ⇒ infeasible on this
|
|
15
|
+
link; REJECT ≤ E < WARN ⇒ feasible with a warning. Thresholds are starting
|
|
16
|
+
values to be recalibrated from ledger data. [assumption]
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
from flashruntime.planner.catalog import INTERCONNECT_GBPS
|
|
24
|
+
from flashruntime.planner.resolve import GB
|
|
25
|
+
|
|
26
|
+
E_REJECT = 0.5
|
|
27
|
+
E_WARN = 0.7
|
|
28
|
+
OVERLAP_FRACTION = 0.5 # fraction of comm hidden under backward compute [assumption]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class CommVerdict:
|
|
33
|
+
efficiency: float # 0..1; 1.0 = no visible communication cost
|
|
34
|
+
feasible: bool
|
|
35
|
+
note: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _link(interconnect: str) -> tuple[float, float]:
|
|
39
|
+
return INTERCONNECT_GBPS[interconnect]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ddp_efficiency(
|
|
43
|
+
grad_bytes: float,
|
|
44
|
+
world: int,
|
|
45
|
+
interconnect: str,
|
|
46
|
+
compute_time_per_step_s: float,
|
|
47
|
+
) -> CommVerdict:
|
|
48
|
+
"""Efficiency of DDP's per-step gradient all-reduce on this link."""
|
|
49
|
+
if world <= 1:
|
|
50
|
+
return CommVerdict(1.0, True, "single worker — no collective traffic")
|
|
51
|
+
bw, lat = _link(interconnect)
|
|
52
|
+
volume = 2.0 * (world - 1) / world * grad_bytes
|
|
53
|
+
t_comm = volume / (bw * GB) + lat
|
|
54
|
+
return _verdict(t_comm, compute_time_per_step_s, interconnect, volume)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def fsdp_efficiency(
|
|
58
|
+
param_bytes: float,
|
|
59
|
+
layers: int,
|
|
60
|
+
world: int,
|
|
61
|
+
interconnect: str,
|
|
62
|
+
compute_time_per_step_s: float,
|
|
63
|
+
) -> CommVerdict:
|
|
64
|
+
"""Efficiency of FSDP2/ZeRO-3 per-layer all-gather + reduce-scatter."""
|
|
65
|
+
if world <= 1:
|
|
66
|
+
return CommVerdict(1.0, True, "single worker — no collective traffic")
|
|
67
|
+
bw, lat = _link(interconnect)
|
|
68
|
+
volume = 3.0 * param_bytes * (world - 1) / world
|
|
69
|
+
t_comm = volume / (bw * GB) + 3.0 * layers * lat
|
|
70
|
+
return _verdict(t_comm, compute_time_per_step_s, interconnect, volume)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _verdict(t_comm: float, t_compute: float, interconnect: str, volume: float) -> CommVerdict:
|
|
74
|
+
if t_compute <= 0:
|
|
75
|
+
# No throughput estimate available: fall back to a link-class gate.
|
|
76
|
+
ok = interconnect not in ("wan",)
|
|
77
|
+
return CommVerdict(
|
|
78
|
+
1.0 if ok else 0.0,
|
|
79
|
+
ok,
|
|
80
|
+
"no compute-time estimate — verdict from link class only",
|
|
81
|
+
)
|
|
82
|
+
exposed = max(0.0, t_comm - OVERLAP_FRACTION * t_compute)
|
|
83
|
+
e = t_compute / (t_compute + exposed)
|
|
84
|
+
mb = volume / 1e6
|
|
85
|
+
if e < E_REJECT:
|
|
86
|
+
note = (
|
|
87
|
+
f"scaling efficiency {e:.2f} < {E_REJECT} on {interconnect} "
|
|
88
|
+
f"({mb:.0f} MB/step) — communication dominates"
|
|
89
|
+
)
|
|
90
|
+
return CommVerdict(round(e, 2), False, note)
|
|
91
|
+
if e < E_WARN:
|
|
92
|
+
note = f"scaling efficiency {e:.2f} on {interconnect} — workable but wasteful"
|
|
93
|
+
else:
|
|
94
|
+
note = f"scaling efficiency {e:.2f} on {interconnect}"
|
|
95
|
+
return CommVerdict(round(e, 2), True, note)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Render a PlanReport as human-readable text (the CLI's output).
|
|
2
|
+
|
|
3
|
+
The explanation is half the product: the selected plan with its arithmetic,
|
|
4
|
+
then every other candidate with why it lost, was infeasible, or violated a
|
|
5
|
+
constraint. Nothing is hidden — a user who can audit the rejections trusts
|
|
6
|
+
the selection.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from flashruntime.protocol.plan_v1alpha1 import CandidateVerdict, PlanReport, StrategyPlan
|
|
12
|
+
|
|
13
|
+
_STATUS_LABEL = {
|
|
14
|
+
"selected": "SELECTED",
|
|
15
|
+
"feasible": "feasible (not chosen)",
|
|
16
|
+
"infeasible": "INFEASIBLE",
|
|
17
|
+
"rejected_policy": "rejected (constraint)",
|
|
18
|
+
"rejected_dominated": "rejected (dominated)",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def render_report(report: PlanReport) -> str:
|
|
23
|
+
lines: list[str] = []
|
|
24
|
+
if report.selected is not None:
|
|
25
|
+
lines.extend(_render_plan(report.selected))
|
|
26
|
+
else:
|
|
27
|
+
lines.append("NO VALID STRATEGY")
|
|
28
|
+
if report.no_valid_strategy_hint:
|
|
29
|
+
lines.append(f" hint: {report.no_valid_strategy_hint}")
|
|
30
|
+
lines.append("")
|
|
31
|
+
lines.append(f"Candidates evaluated ({len(report.candidates)}):")
|
|
32
|
+
for c in sorted(report.candidates, key=_candidate_order):
|
|
33
|
+
lines.extend(_render_candidate(c))
|
|
34
|
+
if report.warnings:
|
|
35
|
+
lines.append("")
|
|
36
|
+
lines.append("Notes:")
|
|
37
|
+
lines.extend(f" - {w}" for w in report.warnings)
|
|
38
|
+
lines.append("")
|
|
39
|
+
lines.append(f"planner {report.planner_version} · request {report.request_digest} · all estimates static unless labeled")
|
|
40
|
+
return "\n".join(lines)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _render_plan(p: StrategyPlan) -> list[str]:
|
|
44
|
+
lines = [
|
|
45
|
+
f"SELECTED PLAN {p.plan_id}",
|
|
46
|
+
f" mode : {p.workload_mode}",
|
|
47
|
+
f" strategy : {p.strategy_family}"
|
|
48
|
+
+ (f" + {p.peft}" if p.peft else "")
|
|
49
|
+
+ (f" + {p.quantization} quantization" if p.quantization else ""),
|
|
50
|
+
f" topology : {p.workers} worker(s)"
|
|
51
|
+
+ (f" × {p.gpus_per_worker} GPU" if p.gpus_per_worker else " (CPU)")
|
|
52
|
+
+ (", colocated" if p.colocated and p.workers > 1 else ""),
|
|
53
|
+
f" launcher : {p.launcher}",
|
|
54
|
+
]
|
|
55
|
+
knobs = []
|
|
56
|
+
if p.precision:
|
|
57
|
+
knobs.append(f"precision={p.precision}")
|
|
58
|
+
if p.micro_batch_per_gpu:
|
|
59
|
+
knobs.append(f"micro_batch={p.micro_batch_per_gpu}")
|
|
60
|
+
if p.grad_accum:
|
|
61
|
+
knobs.append(f"grad_accum={p.grad_accum}")
|
|
62
|
+
if p.activation_checkpointing is not None:
|
|
63
|
+
knobs.append(f"activation_checkpointing={p.activation_checkpointing}")
|
|
64
|
+
if p.offload != "none":
|
|
65
|
+
knobs.append(f"offload={p.offload}")
|
|
66
|
+
if knobs:
|
|
67
|
+
lines.append(f" knobs : {', '.join(knobs)}")
|
|
68
|
+
if p.memory:
|
|
69
|
+
m = p.memory
|
|
70
|
+
lines.append(
|
|
71
|
+
f" memory/GPU: {m.total_gb} GB (weights {m.weights_gb} + grads {m.gradients_gb} + "
|
|
72
|
+
f"optimizer {m.optimizer_gb} + activations {m.activations_gb} + transient {m.transient_gb} "
|
|
73
|
+
f"+ overhead {m.overhead_gb})"
|
|
74
|
+
+ (f" [+{m.cpu_offload_gb} GB host RAM]" if m.cpu_offload_gb else "")
|
|
75
|
+
)
|
|
76
|
+
if p.est_time_min:
|
|
77
|
+
lines.append(f" est. time : {p.est_time_min.value:.0f} min ({p.est_time_min.note})")
|
|
78
|
+
if p.est_cost_usd:
|
|
79
|
+
lines.append(f" est. cost : ${p.est_cost_usd.value:.2f}")
|
|
80
|
+
if p.scaling_efficiency is not None and p.workers > 1:
|
|
81
|
+
lines.append(f" scaling : {p.scaling_efficiency:.2f} efficiency")
|
|
82
|
+
if p.checkpoint:
|
|
83
|
+
lines.append(f" checkpoint: {p.checkpoint.backend} every {p.checkpoint.interval_seconds}s")
|
|
84
|
+
if p.profiling_required:
|
|
85
|
+
lines.append(" ⚠ profiling required before launch (memory estimate inside the caution band)")
|
|
86
|
+
lines.append(" libraries :")
|
|
87
|
+
lines.extend(f" - {ref.name:<28} {ref.role:<10} {ref.purpose}" for ref in p.libraries)
|
|
88
|
+
lines.append(" because :")
|
|
89
|
+
lines.extend(f" - {r}" for r in p.selected_because)
|
|
90
|
+
return lines
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _render_candidate(c: CandidateVerdict) -> list[str]:
|
|
94
|
+
head = f" [{_STATUS_LABEL[c.status]}] {c.name} ({c.workers} worker(s))"
|
|
95
|
+
if c.memory:
|
|
96
|
+
head += f" — {c.memory.total_gb} GB/GPU"
|
|
97
|
+
if c.est_time_min:
|
|
98
|
+
head += f", ~{c.est_time_min.value:.0f} min"
|
|
99
|
+
if c.est_cost_usd:
|
|
100
|
+
head += f", ~${c.est_cost_usd.value:.2f}"
|
|
101
|
+
lines = [head]
|
|
102
|
+
if c.status != "selected": # the winner's reasons already shown in the plan
|
|
103
|
+
lines.extend(f" {r}" for r in c.reasons)
|
|
104
|
+
return lines
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _candidate_order(c: CandidateVerdict):
|
|
108
|
+
order = {"selected": 0, "feasible": 1, "rejected_policy": 2, "rejected_dominated": 3, "infeasible": 4}
|
|
109
|
+
return (order[c.status], c.workers, c.name)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Static per-GPU memory estimation for training strategies.
|
|
2
|
+
|
|
3
|
+
The component model (evaluation §D). All figures are *lower bounds by
|
|
4
|
+
construction* plus a flat overhead; the safety bands in `catalog` — not the
|
|
5
|
+
formulas — are what make launch decisions safe. Real memory depends on
|
|
6
|
+
fragmentation, kernel workspace, and framework version; an OOM in a launched
|
|
7
|
+
job is treated as a planner defect and logged as a regression case.
|
|
8
|
+
|
|
9
|
+
Baseline (mixed-precision AdamW, bytes per parameter):
|
|
10
|
+
weights 2 (bf16) + gradients 2 + fp32 master 4 + Adam m 4 + Adam v 4 = 16
|
|
11
|
+
— the ZeRO paper's classic 16 bytes/param. PEFT changes *which* parameters
|
|
12
|
+
train: frozen weights keep their 2 (or ~0.55 quantized NF4) bytes, while the
|
|
13
|
+
16-byte training state applies only to the small trainable set. Sharding
|
|
14
|
+
divides state across N GPUs; offloading moves it to host RAM (which must
|
|
15
|
+
also be checked — planners that only check VRAM kill hosts).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from flashruntime.planner.catalog import FLAT_OVERHEAD_GB
|
|
23
|
+
from flashruntime.planner.resolve import GB, ResolvedTransformer
|
|
24
|
+
from flashruntime.protocol.plan_v1alpha1 import MemoryBreakdown, TransformerFineTune
|
|
25
|
+
|
|
26
|
+
# Bytes per parameter by dtype.
|
|
27
|
+
_BYTES = {"bf16": 2.0, "fp16": 2.0, "fp32": 4.0}
|
|
28
|
+
# NF4 4-bit quantized frozen weights incl. quantization constants. [assumption]
|
|
29
|
+
_QLORA_FROZEN_BYTES = 0.55
|
|
30
|
+
|
|
31
|
+
# Optimizer-state bytes per *trainable* parameter (fp32 master + moments).
|
|
32
|
+
_OPTIMIZER_BYTES = {
|
|
33
|
+
"adamw": 12.0, # 4 master + 4 m + 4 v
|
|
34
|
+
"adamw_8bit": 6.0, # 4 master + 1 m + 1 v (bitsandbytes 8-bit) [assumption]
|
|
35
|
+
"sgd_momentum": 8.0, # 4 master + 4 momentum
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Activation bytes per (token × hidden) per layer, bf16 + flash-attention era.
|
|
39
|
+
# The single least-predictable constant in this file — pessimistic on
|
|
40
|
+
# purpose; profiling replaces it. [assumption]
|
|
41
|
+
_ACT_BYTES_PER_LAYER = 18.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class ShardingConfig:
|
|
46
|
+
"""How a strategy family divides training state across `world` GPUs.
|
|
47
|
+
|
|
48
|
+
zero1: optimizer sharded. zero2: + gradients. fsdp2/zero3: + weights
|
|
49
|
+
(with a transient per-layer all-gather peak). `offload_optimizer` moves
|
|
50
|
+
optimizer state to host RAM instead.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
world: int = 1
|
|
54
|
+
shard_weights: bool = False
|
|
55
|
+
shard_gradients: bool = False
|
|
56
|
+
shard_optimizer: bool = False
|
|
57
|
+
offload_optimizer: bool = False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def transformer_memory(
|
|
61
|
+
w: TransformerFineTune,
|
|
62
|
+
rt: ResolvedTransformer,
|
|
63
|
+
sharding: ShardingConfig,
|
|
64
|
+
activation_checkpointing: bool,
|
|
65
|
+
) -> MemoryBreakdown:
|
|
66
|
+
"""Estimate per-GPU peak memory for one transformer training candidate."""
|
|
67
|
+
n = max(1, sharding.world)
|
|
68
|
+
quantized = w.method == "qlora"
|
|
69
|
+
|
|
70
|
+
# --- weights -----------------------------------------------------------
|
|
71
|
+
frozen_bytes = _QLORA_FROZEN_BYTES if quantized else _BYTES[w.precision]
|
|
72
|
+
if w.method == "full":
|
|
73
|
+
weights = rt.params * _BYTES[w.precision]
|
|
74
|
+
else:
|
|
75
|
+
# frozen base + LoRA adapters at compute precision
|
|
76
|
+
weights = rt.params * frozen_bytes + rt.trainable_params * _BYTES[w.precision]
|
|
77
|
+
if sharding.shard_weights:
|
|
78
|
+
weights /= n
|
|
79
|
+
|
|
80
|
+
# --- gradients + optimizer (trainable params only) ---------------------
|
|
81
|
+
gradients = rt.trainable_params * _BYTES[w.precision]
|
|
82
|
+
if sharding.shard_gradients:
|
|
83
|
+
gradients /= n
|
|
84
|
+
optimizer = rt.trainable_params * _OPTIMIZER_BYTES[w.optimizer]
|
|
85
|
+
cpu_offload = 0.0
|
|
86
|
+
if sharding.offload_optimizer:
|
|
87
|
+
cpu_offload = optimizer / n if sharding.shard_optimizer else optimizer
|
|
88
|
+
optimizer = 0.0
|
|
89
|
+
elif sharding.shard_optimizer:
|
|
90
|
+
optimizer /= n
|
|
91
|
+
|
|
92
|
+
# --- activations -------------------------------------------------------
|
|
93
|
+
# Full: L layers × seq × micro_batch × hidden × const.
|
|
94
|
+
# With activation checkpointing only layer *inputs* (2 bytes/elem) are
|
|
95
|
+
# kept plus one live layer's activations; recompute costs ~+30% step
|
|
96
|
+
# time (charged in timecost). [assumption]
|
|
97
|
+
tokens = w.seq_len * w.micro_batch_per_gpu
|
|
98
|
+
per_layer = tokens * rt.hidden * _ACT_BYTES_PER_LAYER
|
|
99
|
+
if activation_checkpointing:
|
|
100
|
+
activations = rt.layers * tokens * rt.hidden * 2.0 + per_layer
|
|
101
|
+
else:
|
|
102
|
+
activations = rt.layers * per_layer
|
|
103
|
+
|
|
104
|
+
# --- transient sharding peak ------------------------------------------
|
|
105
|
+
# FSDP2/ZeRO-3 re-materialize one layer group's full parameters during
|
|
106
|
+
# forward/backward all-gather.
|
|
107
|
+
transient = 0.0
|
|
108
|
+
if sharding.shard_weights:
|
|
109
|
+
layer_params = rt.params / rt.layers
|
|
110
|
+
transient = layer_params * _BYTES[w.precision] * 2.0
|
|
111
|
+
|
|
112
|
+
total = weights + gradients + optimizer + activations + transient + FLAT_OVERHEAD_GB * GB
|
|
113
|
+
|
|
114
|
+
return MemoryBreakdown(
|
|
115
|
+
weights_gb=round(weights / GB, 2),
|
|
116
|
+
gradients_gb=round(gradients / GB, 2),
|
|
117
|
+
optimizer_gb=round(optimizer / GB, 2),
|
|
118
|
+
activations_gb=round(activations / GB, 2),
|
|
119
|
+
transient_gb=round(transient / GB, 2),
|
|
120
|
+
overhead_gb=FLAT_OVERHEAD_GB,
|
|
121
|
+
total_gb=round(total / GB, 2),
|
|
122
|
+
cpu_offload_gb=round(cpu_offload / GB, 2),
|
|
123
|
+
basis="static",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def generic_training_memory(
|
|
128
|
+
parameters_m: float,
|
|
129
|
+
trainable_fraction: float,
|
|
130
|
+
precision: str,
|
|
131
|
+
optimizer: str,
|
|
132
|
+
activation_gb: float,
|
|
133
|
+
sharding: ShardingConfig,
|
|
134
|
+
) -> MemoryBreakdown:
|
|
135
|
+
"""Same component model for non-transformer PyTorch training, with
|
|
136
|
+
user-supplied (or pessimistic-default) activation memory."""
|
|
137
|
+
n = max(1, sharding.world)
|
|
138
|
+
params = parameters_m * 1e6
|
|
139
|
+
trainable = params * trainable_fraction
|
|
140
|
+
|
|
141
|
+
weights = params * _BYTES[precision]
|
|
142
|
+
if sharding.shard_weights:
|
|
143
|
+
weights /= n
|
|
144
|
+
gradients = trainable * _BYTES[precision]
|
|
145
|
+
if sharding.shard_gradients:
|
|
146
|
+
gradients /= n
|
|
147
|
+
optimizer_b = trainable * _OPTIMIZER_BYTES[optimizer]
|
|
148
|
+
cpu_offload = 0.0
|
|
149
|
+
if sharding.offload_optimizer:
|
|
150
|
+
cpu_offload = optimizer_b / n if sharding.shard_optimizer else optimizer_b
|
|
151
|
+
optimizer_b = 0.0
|
|
152
|
+
elif sharding.shard_optimizer:
|
|
153
|
+
optimizer_b /= n
|
|
154
|
+
|
|
155
|
+
total = weights + gradients + optimizer_b + activation_gb * GB + FLAT_OVERHEAD_GB * GB
|
|
156
|
+
return MemoryBreakdown(
|
|
157
|
+
weights_gb=round(weights / GB, 2),
|
|
158
|
+
gradients_gb=round(gradients / GB, 2),
|
|
159
|
+
optimizer_gb=round(optimizer_b / GB, 2),
|
|
160
|
+
activations_gb=round(activation_gb, 2),
|
|
161
|
+
transient_gb=0.0,
|
|
162
|
+
overhead_gb=FLAT_OVERHEAD_GB,
|
|
163
|
+
total_gb=round(total / GB, 2),
|
|
164
|
+
cpu_offload_gb=round(cpu_offload / GB, 2),
|
|
165
|
+
basis="static",
|
|
166
|
+
)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Resolve a PlanRequest into the concrete numbers the estimators need.
|
|
2
|
+
|
|
3
|
+
Turns "Qwen/Qwen2.5-7B, LoRA r=16, 4×24 GB" into parameter counts, model
|
|
4
|
+
shape, trainable-parameter counts, and per-GPU VRAM — recording which values
|
|
5
|
+
were user-given, catalog-known, or derived, so estimates can say so.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from flashruntime.planner import catalog
|
|
13
|
+
from flashruntime.protocol.plan_v1alpha1 import PlanRequest, Resources, TransformerFineTune
|
|
14
|
+
|
|
15
|
+
GB = 1e9 # this module talks in GB = 1e9 bytes throughout
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ResolvedTransformer:
|
|
20
|
+
"""A TransformerFineTune with every blank filled in."""
|
|
21
|
+
|
|
22
|
+
params: float # absolute parameter count
|
|
23
|
+
hidden: int
|
|
24
|
+
layers: int
|
|
25
|
+
trainable_params: float # LoRA adapters, or all params for full FT
|
|
26
|
+
shape_derived: bool # True when hidden/layers came from a heuristic
|
|
27
|
+
notes: list[str] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_transformer(w: TransformerFineTune) -> ResolvedTransformer:
|
|
31
|
+
"""Fill parameter count and shape from (in priority order) user input,
|
|
32
|
+
the model catalog, or the size-class heuristic."""
|
|
33
|
+
notes: list[str] = []
|
|
34
|
+
info = catalog.lookup_model(w.model)
|
|
35
|
+
|
|
36
|
+
if w.parameters_b is not None:
|
|
37
|
+
params_b = w.parameters_b
|
|
38
|
+
elif info is not None:
|
|
39
|
+
params_b = info.parameters_b
|
|
40
|
+
notes.append(f"parameter count {params_b}B taken from model catalog for '{w.model}'")
|
|
41
|
+
else:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"model '{w.model}' is not in the planner catalog — set workload.parameters_b "
|
|
44
|
+
"(billions of parameters) explicitly"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
shape_derived = False
|
|
48
|
+
if w.hidden_size and w.num_layers:
|
|
49
|
+
hidden, layers = w.hidden_size, w.num_layers
|
|
50
|
+
elif info is not None:
|
|
51
|
+
hidden, layers = info.hidden_size, info.num_layers
|
|
52
|
+
else:
|
|
53
|
+
hidden, layers = catalog.derive_transformer_shape(params_b)
|
|
54
|
+
shape_derived = True
|
|
55
|
+
notes.append(
|
|
56
|
+
f"model shape derived from size class ({hidden} hidden × {layers} layers) [assumption]"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
params = params_b * 1e9
|
|
60
|
+
if w.method == "full":
|
|
61
|
+
trainable = params
|
|
62
|
+
else:
|
|
63
|
+
# LoRA on the four attention projections (q,k,v,o), the common
|
|
64
|
+
# default: each gets A(h×r)+B(r×h) = 2·h·r params → 8·h·r per layer.
|
|
65
|
+
# [assumption: adapters on attention only; all-linear LoRA is ~2–3×]
|
|
66
|
+
trainable = 8.0 * hidden * w.lora_rank * layers
|
|
67
|
+
notes.append(
|
|
68
|
+
f"LoRA trainable params ≈ {trainable / 1e6:.1f}M "
|
|
69
|
+
f"(8·hidden·rank·layers, attention projections) [assumption]"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return ResolvedTransformer(
|
|
73
|
+
params=params,
|
|
74
|
+
hidden=hidden,
|
|
75
|
+
layers=layers,
|
|
76
|
+
trainable_params=trainable,
|
|
77
|
+
shape_derived=shape_derived,
|
|
78
|
+
notes=notes,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class ResolvedGPU:
|
|
84
|
+
"""Per-GPU capability actually used by the estimators."""
|
|
85
|
+
|
|
86
|
+
vram_gb: float
|
|
87
|
+
bf16_tflops: float
|
|
88
|
+
tflops_known: bool
|
|
89
|
+
notes: list[str] = field(default_factory=list)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def resolve_gpu(r: Resources) -> ResolvedGPU:
|
|
93
|
+
"""VRAM: user value wins, then catalog. Throughput: catalog only —
|
|
94
|
+
without it, time/cost estimates are skipped rather than invented."""
|
|
95
|
+
info = catalog.lookup_gpu(r.gpu_type)
|
|
96
|
+
notes: list[str] = []
|
|
97
|
+
|
|
98
|
+
if r.vram_gb is not None:
|
|
99
|
+
vram = r.vram_gb
|
|
100
|
+
elif info is not None:
|
|
101
|
+
vram = info.vram_gb
|
|
102
|
+
notes.append(f"VRAM {vram} GB taken from GPU catalog for '{r.gpu_type}'")
|
|
103
|
+
else:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"unknown GPU type '{r.gpu_type}' — set resources.vram_gb explicitly"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if info is not None:
|
|
109
|
+
return ResolvedGPU(vram, info.bf16_tflops, True, notes)
|
|
110
|
+
notes.append("GPU throughput unknown — time/cost estimates unavailable")
|
|
111
|
+
return ResolvedGPU(vram, 0.0, False, notes)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def request_digest(req: PlanRequest, planner_version: str) -> str:
|
|
115
|
+
"""Stable content hash over the request + planner version — the identity
|
|
116
|
+
of a planning decision (same inputs ⇒ same digest ⇒ same plan)."""
|
|
117
|
+
import hashlib
|
|
118
|
+
|
|
119
|
+
payload = req.model_dump_json() + planner_version
|
|
120
|
+
return hashlib.sha256(payload.encode()).hexdigest()[:16]
|