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