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,156 @@
1
+ """Strategy compilers: a frozen `StrategyPlan` → concrete launch configuration.
2
+
3
+ This is the seam that keeps the planner honest: the planner reasons about
4
+ strategies *by name and number* (never importing frameworks), and a
5
+ compiler here translates the chosen plan into the real thing — torchrun
6
+ argv + env for DDP/FSDP2, a DeepSpeed JSON config file for ZeRO families,
7
+ a lease-job JobSpec for `lease_tasks`. One compiler per strategy family;
8
+ the registry dispatches on `supports()`.
9
+
10
+ Design rules (ADR-0003, HANDBOOK §5):
11
+ - Compilers may import framework *constants* knowledge but must not
12
+ require the framework installed to *compile* (emitting an argv string
13
+ needs no torch). Anything that must run framework code belongs in the
14
+ launcher or the task, not here.
15
+ - Compilation is pure and deterministic: same plan ⇒ same LaunchSpec.
16
+ No environment inspection, no network. Preflight checks (is torchrun on
17
+ PATH? is the image pullable?) belong to `Launcher.healthy()`.
18
+ - A compiler must refuse loudly (`CompileError` listing every problem)
19
+ rather than emit a config it cannot stand behind — the same fail-closed
20
+ stance as the executor allowlists.
21
+
22
+ Status: interface complete (final surface, by design review); concrete
23
+ compilers land per SPRINT_PLAN (torchrun/DDP first, with the LoRA recipe).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from abc import ABC, abstractmethod
29
+ from typing import ClassVar
30
+
31
+ from pydantic import BaseModel, Field
32
+
33
+ from flashruntime.protocol.plan_v1alpha1 import StrategyPlan
34
+
35
+ __all__ = [
36
+ "LaunchSpec",
37
+ "StrategyCompiler",
38
+ "CompileError",
39
+ "register_compiler",
40
+ "compiler_for",
41
+ ]
42
+
43
+
44
+ class CompileError(Exception):
45
+ """The plan cannot be compiled by this family — carries *all* reasons
46
+ at once (like planner rejections: half the value is the explanation)."""
47
+
48
+
49
+ class LaunchSpec(BaseModel):
50
+ """Everything a `Launcher` needs to start one worker group / job.
51
+
52
+ Backend-neutral on purpose: the same model describes a torchrun
53
+ process group, a DeepSpeed launch, or a Mode A lease job. Fields a
54
+ given launcher does not need are simply ignored by it.
55
+ """
56
+
57
+ argv: list[str] = Field(description="Command to execute per worker (rank 0's view)")
58
+ env: dict[str, str] = Field(
59
+ default_factory=dict,
60
+ description="Environment to merge over the launcher's base env "
61
+ "(e.g. WORLD_SIZE, precision flags). Never secrets — credentials "
62
+ "are the launcher's concern.",
63
+ )
64
+ files: dict[str, str] = Field(
65
+ default_factory=dict,
66
+ description="Config files to materialize into the attempt OUTPUT dir "
67
+ "(the one exported as FLASHML_OUTPUT_DIR) before launch, name → "
68
+ "content (e.g. 'ds_config.json' for DeepSpeed). Keeps compilation "
69
+ "pure while supporting file-driven frameworks. Note: files land in "
70
+ "the output dir, NOT the command's cwd — wiring cwd-reading "
71
+ "frameworks to find them is a flagged follow-up to settle before any "
72
+ "files-emitting compiler ships.",
73
+ )
74
+ world_size: int = Field(default=1, ge=1, description="Total processes across all nodes")
75
+ gpus_per_process: int = Field(default=0, ge=0)
76
+ rendezvous: dict[str, str] = Field(
77
+ default_factory=dict,
78
+ description="Rendezvous hints the launcher resolves at start time "
79
+ "(e.g. {'backend': 'c10d'}). Addresses/ports are ALWAYS resolved "
80
+ "by the launcher — a compiled plan must stay host-agnostic.",
81
+ )
82
+ workdir_hint: str = Field(
83
+ default="", description="Relative workdir layout the command expects, if any"
84
+ )
85
+ notes: list[str] = Field(
86
+ default_factory=list,
87
+ description="Human-readable compilation notes for the ledger "
88
+ "(mirrors the planner's explanation habit).",
89
+ )
90
+
91
+
92
+ class StrategyCompiler(ABC):
93
+ """One strategy family's translator.
94
+
95
+ Lifecycle: `supports(plan)` → `validate(plan)` → `compile(plan)`.
96
+ The registry calls `supports` to route; callers may run `validate`
97
+ separately for dry-run UX (surface every problem before spending
98
+ anything); `compile` implies validation and raises `CompileError` on
99
+ any defect.
100
+ """
101
+
102
+ #: Family name this compiler owns — must match
103
+ #: `StrategyPlan.strategy_family` values (e.g. "ddp", "fsdp2",
104
+ #: "zero3_cpu_offload", "lease_tasks").
105
+ family: ClassVar[str]
106
+
107
+ @abstractmethod
108
+ def supports(self, plan: StrategyPlan) -> bool:
109
+ """Cheap routing predicate. Must not raise; unknown plans → False."""
110
+
111
+ def validate(self, plan: StrategyPlan) -> list[str]:
112
+ """Return every reason this plan cannot compile (empty = fine).
113
+
114
+ Default: no extra checks beyond `supports`. Override to verify
115
+ family-specific invariants (e.g. FSDP2 requires workers > 1;
116
+ DeepSpeed offload requires `plan.offload != 'none'`). Never check
117
+ the *environment* here — that is `Launcher.healthy()`.
118
+ """
119
+ return [] if self.supports(plan) else [f"{self.family}: unsupported plan"]
120
+
121
+ @abstractmethod
122
+ def compile(self, plan: StrategyPlan) -> LaunchSpec:
123
+ """Translate the plan into a LaunchSpec.
124
+
125
+ Input: a frozen, planner-emitted StrategyPlan (treat as immutable).
126
+ Output: a complete LaunchSpec — argv, env, config files.
127
+ Raises: CompileError with *all* problems when validation fails.
128
+ Determinism contract: identical plan ⇒ identical LaunchSpec
129
+ (the ledger records the spec hash next to the plan_id).
130
+ """
131
+
132
+
133
+ _REGISTRY: list[StrategyCompiler] = []
134
+
135
+
136
+ def register_compiler(compiler: StrategyCompiler) -> None:
137
+ """Register a compiler instance (idempotent per family: a re-register
138
+ of the same family replaces the previous one — supports test setups
139
+ and plugin reloads)."""
140
+ _REGISTRY[:] = [c for c in _REGISTRY if c.family != compiler.family]
141
+ _REGISTRY.append(compiler)
142
+
143
+
144
+ def compiler_for(plan: StrategyPlan) -> StrategyCompiler:
145
+ """Dispatch: first registered compiler whose `supports(plan)` is True.
146
+
147
+ Raises LookupError naming the family — the caller surfaces this as a
148
+ 422 (\"plan compiled by no installed strategy family\"), never a 500.
149
+ """
150
+ for compiler in _REGISTRY:
151
+ if compiler.supports(plan):
152
+ return compiler
153
+ raise LookupError(
154
+ f"no strategy compiler for family {plan.strategy_family!r} "
155
+ f"(registered: {[c.family for c in _REGISTRY] or 'none'})"
156
+ )
@@ -0,0 +1,56 @@
1
+ """Compile a CommandWorkload into the backend-neutral LaunchSpec.
2
+
3
+ Pure and deterministic (same rules as StrategyCompiler): no environment
4
+ inspection, no filesystem access — resolving the workdir and creating
5
+ output directories is the launcher's job.
6
+
7
+ Note: this is a module function, not a StrategyCompiler subclass — a
8
+ StrategyPlan carries no argv, so a plan-driven compiler for commands is
9
+ meaningless until flash.run() wiring lands (spec §10 follow-up).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from flashruntime.strategies import LaunchSpec
15
+ from flashruntime.workloads.command import CommandWorkload
16
+
17
+
18
+ def compile_workload(workload: CommandWorkload, params: dict | None = None) -> LaunchSpec:
19
+ argv = workload.argv(params)
20
+ env = {k: (v.format(**params) if params else v) for k, v in workload.env.items()}
21
+ world_size = 1
22
+ notes = [f"mode={workload.resolved_mode()}"]
23
+ if argv and argv[0] == "torchrun":
24
+ # torchrun's worker count flag has two spellings (hyphen/underscore)
25
+ # and two forms (--flag=N or --flag N). Values may also be
26
+ # symbolic (auto/gpu/cpu), resolved by torchrun at launch — those
27
+ # must not crash compilation: leave world_size=1 and note it.
28
+ nproc_flags = ("--nproc-per-node", "--nproc_per_node")
29
+ i = 1
30
+ while i < len(argv):
31
+ token = argv[i]
32
+ flag = value = None
33
+ if "=" in token:
34
+ head, _, tail = token.partition("=")
35
+ if head in nproc_flags:
36
+ flag, value = head, tail
37
+ elif token in nproc_flags:
38
+ flag = token
39
+ value = argv[i + 1] if i + 1 < len(argv) else None
40
+ i += 1 # consume the value token
41
+ if flag is not None and value is not None:
42
+ try:
43
+ world_size = int(value)
44
+ notes.append(f"world_size from torchrun: {world_size}")
45
+ except ValueError:
46
+ notes.append(
47
+ f"world_size unresolved: {flag}={value} (resolved at launch time)"
48
+ )
49
+ i += 1
50
+ return LaunchSpec(
51
+ argv=argv,
52
+ env=env,
53
+ world_size=world_size,
54
+ workdir_hint=workload.source.path,
55
+ notes=notes,
56
+ )
@@ -0,0 +1,274 @@
1
+ """In-training-script helper: one import makes a PyTorch script
2
+ launch-anywhere and fault-tolerant.
3
+
4
+ import flashruntime.torch as ft
5
+ model, opt, loader = ft.prepare(model, opt, loader)
6
+ ...
7
+ ft.checkpoint(model, opt, step=step, every=100)
8
+ ft.log_metrics({"loss": float(loss)})
9
+
10
+ Launched by torchrun (WORLD_SIZE>1): prepare() wires torch's OWN DDP +
11
+ DistributedSampler and restores the newest VALID checkpoint manifest.
12
+ Launched as plain `python train.py`: prepare() is a no-op passthrough.
13
+
14
+ GUARDRAIL (ADR-0003 — do not rebuild Accelerate): this module wires
15
+ torch's own primitives and REPORTS launch facts — nothing more (that
16
+ reporting includes a per-rank heartbeat file the run viewer reads). The
17
+ surface is prepare / checkpoint / log_metrics plus read-only accessors
18
+ (rank / world_size / is_main / start_step / device / backend). The
19
+ boundary is capability, not count: no FSDP policies, no autocast, no
20
+ DeepSpeed config, no strategy knobs — ever. Users wanting those use the
21
+ real framework features, which the launcher still launches correctly.
22
+
23
+ torch is imported inside functions only: flashruntime's core never
24
+ depends on it.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import time
32
+ from pathlib import Path
33
+
34
+ __all__ = [
35
+ "prepare",
36
+ "checkpoint",
37
+ "log_metrics",
38
+ "rank",
39
+ "world_size",
40
+ "is_main",
41
+ "start_step",
42
+ "device",
43
+ "backend",
44
+ ]
45
+
46
+ _restored_step = 0
47
+ # Set by prepare() from launch facts so a script can REPORT what it ran on
48
+ # (device string + torch.distributed backend) without re-deriving it. Both
49
+ # stay at their launched-single-process defaults until prepare() runs.
50
+ _device = "cpu"
51
+ _backend: str | None = None
52
+
53
+ # Heartbeat state: the run viewer draws machine → worker → rank from a small
54
+ # per-rank JSON each process mirrors to ranks/rank-N.json. Throttled so a
55
+ # tight training loop calling log_metrics()/checkpoint() every step costs at
56
+ # most one small atomic write per second per rank.
57
+ _last_beat = 0.0
58
+ _last_step: int | None = None
59
+ _BEAT_MIN_INTERVAL_S = 1.0
60
+
61
+
62
+ def _write_heartbeat(step: int | None = None, force: bool = False) -> None:
63
+ """Mirror this rank's identity + progress to ranks/rank-<N>.json.
64
+
65
+ Atomic (tmp + os.replace, the run.json idiom) so the viewer never reads
66
+ a torn file. Best-effort by contract: observability must never be able
67
+ to crash training, so every failure is swallowed. `step=None` reuses the
68
+ last step this process reported (a refresh must not erase progress)."""
69
+ global _last_beat, _last_step
70
+ if step is not None:
71
+ _last_step = step
72
+ now = time.time()
73
+ if not force and now - _last_beat < _BEAT_MIN_INTERVAL_S:
74
+ return
75
+ try:
76
+ beat = {
77
+ "rank": rank(),
78
+ "local_rank": int(os.environ.get("LOCAL_RANK", "0")),
79
+ "pid": os.getpid(),
80
+ "device": _device,
81
+ "backend": _backend,
82
+ "world_size": world_size(),
83
+ "step": _last_step,
84
+ "ts": now,
85
+ }
86
+ ranks_dir = _output_dir() / "ranks"
87
+ ranks_dir.mkdir(parents=True, exist_ok=True)
88
+ tmp = ranks_dir / f".rank-{rank()}.{os.getpid()}.tmp"
89
+ tmp.write_text(json.dumps(beat))
90
+ os.replace(tmp, ranks_dir / f"rank-{rank()}.json")
91
+ _last_beat = now
92
+ except Exception: # noqa: BLE001 — by contract, swallow everything
93
+ pass
94
+
95
+
96
+ def _resolve_device(world_size: int, cuda_available: bool, local_rank: int) -> str:
97
+ """Decide the training device from launch facts alone — returns ``"cpu"``
98
+ or ``f"cuda:{local_rank}"``.
99
+
100
+ Kept pure (no torch import, no globals) so the device *decision* is
101
+ testable on this CPU-only box: the CUDA code paths in ``prepare`` that
102
+ consume the result cannot execute here, but the choice they depend on
103
+ can. ``world_size`` is part of the launch-facts signature; the device
104
+ string itself depends only on whether CUDA is present and which local
105
+ rank this process owns.
106
+ """
107
+ if cuda_available:
108
+ return f"cuda:{local_rank}"
109
+ return "cpu"
110
+
111
+
112
+ def world_size() -> int:
113
+ return int(os.environ.get("WORLD_SIZE", "1"))
114
+
115
+
116
+ def rank() -> int:
117
+ return int(os.environ.get("RANK", "0"))
118
+
119
+
120
+ def is_main() -> bool:
121
+ return rank() == 0
122
+
123
+
124
+ def start_step() -> int:
125
+ """First step the training loop should run: 0 fresh, >0 after a
126
+ resume (set by prepare() when it restores a checkpoint)."""
127
+ return _restored_step
128
+
129
+
130
+ def device() -> str:
131
+ """The device the last ``prepare()`` placed this rank's model on —
132
+ ``"cpu"`` or ``f"cuda:{local_rank}"``. Lets a script report where it
133
+ trained (e.g. into ``metrics.json``) without re-deriving the choice."""
134
+ return _device
135
+
136
+
137
+ def backend() -> str | None:
138
+ """The ``torch.distributed`` backend the last ``prepare()`` initialized
139
+ (``"nccl"`` on GPU, ``"gloo"`` on CPU), or ``None`` when launched
140
+ single-process (no process group)."""
141
+ return _backend
142
+
143
+
144
+ def _output_dir() -> Path:
145
+ return Path(os.environ.get("FLASHML_OUTPUT_DIR", "."))
146
+
147
+
148
+ def _ckpt_root() -> Path:
149
+ # job-scoped (NOT attempt-scoped): a restarted attempt must find its
150
+ # predecessor's manifests — the launcher exports FLASHML_CKPT_DIR
151
+ root = os.environ.get("FLASHML_CKPT_DIR")
152
+ return Path(root) if root else _output_dir() / "ckpt"
153
+
154
+
155
+ def prepare(model, optimizer=None, dataloader=None):
156
+ """Wire distributed execution (when launched distributed) and restore
157
+ the newest valid checkpoint (when one exists). Returns the possibly
158
+ wrapped/rebuilt (model, optimizer, dataloader) triple."""
159
+ global _restored_step, _device, _backend
160
+ import torch
161
+
162
+ local_rank = int(os.environ.get("LOCAL_RANK", "0"))
163
+ device = _resolve_device(world_size(), torch.cuda.is_available(), local_rank)
164
+ on_cuda = device != "cpu"
165
+ _device = device # remember for device()/metrics reporting
166
+
167
+ if on_cuda:
168
+ # place the model on this rank's GPU BEFORE the DDP wrap (and for a
169
+ # single-process GPU box too — the "just works on a GPU" path). This
170
+ # branch is CUDA-only and never runs on the CPU-only test machine.
171
+ torch.cuda.set_device(local_rank)
172
+ model = model.to(device)
173
+
174
+ if world_size() > 1:
175
+ import torch.distributed as dist
176
+
177
+ _backend = "nccl" if torch.cuda.is_available() else "gloo"
178
+ if not dist.is_initialized():
179
+ dist.init_process_group(backend=_backend)
180
+ if on_cuda:
181
+ # bind DDP to the device; gloo/CPU must keep the no-args wrap.
182
+ model = torch.nn.parallel.DistributedDataParallel(
183
+ model, device_ids=[local_rank], output_device=local_rank
184
+ )
185
+ else:
186
+ model = torch.nn.parallel.DistributedDataParallel(model)
187
+ if dataloader is not None:
188
+ from torch.utils.data import DataLoader
189
+ from torch.utils.data.distributed import DistributedSampler
190
+
191
+ dataloader = DataLoader(
192
+ dataloader.dataset,
193
+ batch_size=dataloader.batch_size,
194
+ sampler=DistributedSampler(dataloader.dataset),
195
+ collate_fn=dataloader.collate_fn,
196
+ num_workers=dataloader.num_workers,
197
+ drop_last=dataloader.drop_last,
198
+ )
199
+
200
+ from flashruntime.checkpoint.local import latest_valid_manifest
201
+
202
+ manifest = latest_valid_manifest(_ckpt_root())
203
+ if manifest is not None:
204
+ step_dir = Path(manifest.storage_prefix)
205
+ target = model.module if hasattr(model, "module") else model
206
+ # load straight onto the live device: CPU-saved (device-agnostic)
207
+ # parts map onto "cpu" (unchanged) or this rank's "cuda:N".
208
+ target.load_state_dict(torch.load(step_dir / "model.pt", map_location=device))
209
+ if optimizer is not None and (step_dir / "optimizer.pt").is_file():
210
+ optimizer.load_state_dict(torch.load(step_dir / "optimizer.pt", map_location=device))
211
+ _restored_step = manifest.step
212
+
213
+ # announce this rank to the run viewer the moment it is wired
214
+ _write_heartbeat(step=_restored_step, force=True)
215
+ return model, optimizer, dataloader
216
+
217
+
218
+ def checkpoint(model, optimizer=None, *, step: int, every: int | None = None) -> None:
219
+ """Write a resumable checkpoint under the manifest contract (parts
220
+ first, manifest last). rank 0 writes; every rank synchronizes on the
221
+ barrier so no one races past a half-written checkpoint.
222
+
223
+ `every=None` (default) checkpoints unconditionally; `every=N` gates to
224
+ every Nth step; `every<=0` means "no periodic checkpointing" and is a
225
+ no-op — a helper whose contract is fault tolerance must never itself
226
+ crash training over a config value (found by the benchmark suite:
227
+ `every=0` used to raise ZeroDivisionError)."""
228
+ _write_heartbeat(step=step) # progress signal — fires even on gated calls
229
+ if every is not None and (every <= 0 or step == 0 or step % every != 0):
230
+ return
231
+ import torch
232
+
233
+ if is_main():
234
+ from flashruntime.checkpoint.local import write_manifest
235
+
236
+ step_dir = _ckpt_root() / f"step-{step:06d}"
237
+ step_dir.mkdir(parents=True, exist_ok=True)
238
+ target = model.module if hasattr(model, "module") else model
239
+ # detach + move to CPU so the saved parts are device-agnostic: a
240
+ # checkpoint trained on cuda:3 must restore onto cpu or a box with
241
+ # fewer GPUs (map_location handles the rest at restore time).
242
+ model_state = {k: v.detach().cpu() for k, v in target.state_dict().items()}
243
+ torch.save(model_state, step_dir / "model.pt")
244
+ if optimizer is not None:
245
+ torch.save(optimizer.state_dict(), step_dir / "optimizer.pt")
246
+ write_manifest(
247
+ step_dir,
248
+ job_id=os.environ.get("FLASHML_JOB_ID", "local"),
249
+ attempt_id=os.environ.get("FLASHML_ATTEMPT_ID", "local"),
250
+ step=step,
251
+ world_size=world_size(),
252
+ framework=f"pytorch-{torch.__version__.split('+')[0]}",
253
+ )
254
+ if world_size() > 1:
255
+ import torch.distributed as dist
256
+
257
+ dist.barrier()
258
+
259
+
260
+ def log_metrics(metrics: dict) -> None:
261
+ """Append one JSON record to FLASHML_OUTPUT_DIR/metrics.jsonl (rank 0
262
+ only). Never raises — metrics must never kill training."""
263
+ # every rank beats (identity/progress); only rank 0 appends metrics below
264
+ step = metrics.get("step") if isinstance(metrics, dict) else None
265
+ _write_heartbeat(step=step if isinstance(step, int) else None)
266
+ if not is_main():
267
+ return
268
+ try:
269
+ path = _output_dir() / "metrics.jsonl"
270
+ path.parent.mkdir(parents=True, exist_ok=True)
271
+ with open(path, "a") as f:
272
+ f.write(json.dumps(metrics) + "\n")
273
+ except Exception: # noqa: BLE001 — by contract, swallow everything
274
+ pass
@@ -0,0 +1,20 @@
1
+ """The stdlib run viewer: a read-only window onto a live run's directory.
2
+
3
+ Two pieces, both stdlib-only so `import flashruntime.viewer` stays clean-core
4
+ importable (no numpy/torch/fastapi): `state.collect()` assembles the
5
+ `/api/state` snapshot from disk, and `server.RunViewerServer` serves it (plus
6
+ the page and the docs) over HTTP. The only flashruntime dependency is
7
+ `flashruntime.checkpoint.local` for manifest hash-verification — itself
8
+ core-safe (stdlib + pydantic).
9
+
10
+ Contract source: the `viewer_v1` run.json written by `flashruntime.sdk.Run`.
11
+ The viewer consumes that versioned contract and NOTHING else about the SDK's
12
+ internals, so the two evolve independently (spec §2b, horizontal
13
+ extensibility).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from flashruntime.viewer.state import collect
19
+
20
+ __all__ = ["collect"]