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,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]