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,109 @@
|
|
|
1
|
+
"""Local checkpoint manifests: parts-first / manifest-last for processes
|
|
2
|
+
that have only a filesystem (no coordinator).
|
|
3
|
+
|
|
4
|
+
`write_manifest` hashes every part file already on disk and writes
|
|
5
|
+
manifest.json LAST — a crash mid-checkpoint leaves part files but no
|
|
6
|
+
manifest, so the checkpoint does not exist. `latest_valid_manifest`
|
|
7
|
+
re-verifies every part hash on read: a corrupted or truncated part
|
|
8
|
+
disqualifies its manifest, so recovery can never restore from it.
|
|
9
|
+
|
|
10
|
+
Consumers: `flashruntime.torch.checkpoint()` and the Hugging Face Trainer
|
|
11
|
+
callback. Pure stdlib + pydantic (protocol models) — safe in the core.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import uuid
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from flashruntime.protocol.v1alpha1 import (
|
|
21
|
+
CheckpointManifest,
|
|
22
|
+
CheckpointPart,
|
|
23
|
+
CheckpointValidation,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
MANIFEST_NAME = "manifest.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _sha256(path: Path) -> str:
|
|
30
|
+
digest = hashlib.sha256()
|
|
31
|
+
with open(path, "rb") as f:
|
|
32
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
33
|
+
digest.update(chunk)
|
|
34
|
+
return digest.hexdigest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def write_manifest(
|
|
38
|
+
step_dir: Path,
|
|
39
|
+
*,
|
|
40
|
+
job_id: str,
|
|
41
|
+
attempt_id: str,
|
|
42
|
+
step: int,
|
|
43
|
+
world_size: int = 1,
|
|
44
|
+
framework: str = "",
|
|
45
|
+
) -> CheckpointManifest:
|
|
46
|
+
"""Hash every part file in `step_dir`, then write the manifest LAST."""
|
|
47
|
+
step_dir = Path(step_dir)
|
|
48
|
+
parts = [
|
|
49
|
+
CheckpointPart(key=p.name, sha256=_sha256(p), size_bytes=p.stat().st_size)
|
|
50
|
+
for p in sorted(step_dir.iterdir())
|
|
51
|
+
if p.is_file() and p.name != MANIFEST_NAME
|
|
52
|
+
]
|
|
53
|
+
if not parts:
|
|
54
|
+
raise ValueError(f"no part files in {step_dir}")
|
|
55
|
+
manifest = CheckpointManifest(
|
|
56
|
+
manifest_id=f"ck-{uuid.uuid4().hex[:12]}",
|
|
57
|
+
job_id=job_id,
|
|
58
|
+
attempt_id=attempt_id,
|
|
59
|
+
step=step,
|
|
60
|
+
framework=framework,
|
|
61
|
+
world_size=world_size,
|
|
62
|
+
compatible_world_sizes=[world_size],
|
|
63
|
+
storage_prefix=str(step_dir),
|
|
64
|
+
parts=parts,
|
|
65
|
+
validation=CheckpointValidation.HASH_VERIFIED,
|
|
66
|
+
)
|
|
67
|
+
(step_dir / MANIFEST_NAME).write_text(manifest.model_dump_json(indent=2)) # LAST
|
|
68
|
+
return manifest
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def verify_manifest(manifest: CheckpointManifest, step_dir: Path) -> bool:
|
|
72
|
+
"""True iff every part named in `manifest` is present in `step_dir` and
|
|
73
|
+
re-hashes to its recorded sha256 — i.e. this checkpoint is safe to
|
|
74
|
+
restore. The single source of truth for that question: both
|
|
75
|
+
`latest_valid_manifest` (recovery's picker) and the viewer's per-step
|
|
76
|
+
listing call it, so the part-hashing lives in exactly one place and can
|
|
77
|
+
never drift between the two. Never raises: a part we cannot re-hash
|
|
78
|
+
(directory, unreadable, gone) means the manifest cannot be verified, so
|
|
79
|
+
it is simply invalid."""
|
|
80
|
+
step_dir = Path(step_dir)
|
|
81
|
+
try:
|
|
82
|
+
return all(
|
|
83
|
+
(step_dir / part.key).is_file() and _sha256(step_dir / part.key) == part.sha256
|
|
84
|
+
for part in manifest.parts
|
|
85
|
+
)
|
|
86
|
+
except (OSError, ValueError):
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def latest_valid_manifest(ckpt_root: Path, pattern: str = "step-*") -> CheckpointManifest | None:
|
|
91
|
+
"""Newest manifest whose parts all re-verify on disk, or None.
|
|
92
|
+
|
|
93
|
+
`pattern` matches the per-step directory names ("step-*" for
|
|
94
|
+
flashruntime.torch, "checkpoint-*" for Hugging Face Trainer output).
|
|
95
|
+
"""
|
|
96
|
+
ckpt_root = Path(ckpt_root)
|
|
97
|
+
if not ckpt_root.is_dir():
|
|
98
|
+
return None
|
|
99
|
+
best: CheckpointManifest | None = None
|
|
100
|
+
for mf_path in ckpt_root.glob(f"{pattern}/{MANIFEST_NAME}"):
|
|
101
|
+
try:
|
|
102
|
+
manifest = CheckpointManifest.model_validate_json(mf_path.read_text())
|
|
103
|
+
except (OSError, ValueError):
|
|
104
|
+
# unreadable/invalid manifest (bad JSON, or manifest.json is a
|
|
105
|
+
# directory / unreadable file → OSError): treat as nonexistent
|
|
106
|
+
continue
|
|
107
|
+
if verify_manifest(manifest, mf_path.parent) and (best is None or manifest.step > best.step):
|
|
108
|
+
best = manifest
|
|
109
|
+
return best
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Manifest persistence seam (research item R1).
|
|
2
|
+
|
|
3
|
+
Today `CheckpointCatalog` keeps manifests in a plain dict — a coordinator
|
|
4
|
+
restart orphans perfectly good checkpoint *files* because the metadata
|
|
5
|
+
proving their validity dies with the process. This module defines the
|
|
6
|
+
persistence interface that closes that gap, mirroring the lease pattern
|
|
7
|
+
that already works (`leases/store.py` + `leases/sqlite_store.py`):
|
|
8
|
+
|
|
9
|
+
the catalog mutates live objects in an insertion-ordered cache and
|
|
10
|
+
calls `save(manifest)` after every transition; a durable store
|
|
11
|
+
persists there and rehydrates the cache at construction.
|
|
12
|
+
|
|
13
|
+
Migration path (half a day, SPRINT_PLAN Day 3):
|
|
14
|
+
1. `CheckpointCatalog.__init__` grows `store: ManifestStore | None = None`
|
|
15
|
+
(default `InMemoryManifestStore()` — zero behavior change).
|
|
16
|
+
2. The catalog's `_manifests` dict is replaced by the store's cache, and
|
|
17
|
+
every mutation (`commit`, `mark_restore_verified`, `quarantine`) ends
|
|
18
|
+
with `store.save(manifest)`.
|
|
19
|
+
3. `SqliteManifestStore` lands beside `leases.db` (one table: manifest_id
|
|
20
|
+
PK, scope job_id, manifest JSON, seq for order) with the same
|
|
21
|
+
COALESCE-seq upsert as `SqliteLeaseStore._persist` — copy that code,
|
|
22
|
+
it is tested and correct.
|
|
23
|
+
4. The conformance test is `tests/test_checkpoint.py` re-run against the
|
|
24
|
+
new store, plus a restart-survival test shaped exactly like
|
|
25
|
+
`tests/test_leases_sqlite.py::test_inflight_work_survives_coordinator_restart`.
|
|
26
|
+
|
|
27
|
+
Note the pending-parts registry (`CheckpointCatalog._registered`) is
|
|
28
|
+
deliberately NOT persisted: parts without a committed manifest are, by the
|
|
29
|
+
manifest-last rule, not checkpoints — losing them on restart just means
|
|
30
|
+
the in-flight checkpoint re-uploads, which the relay already handles.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from typing import Protocol
|
|
36
|
+
|
|
37
|
+
from flashruntime.protocol.v1alpha1 import CheckpointManifest
|
|
38
|
+
|
|
39
|
+
__all__ = ["ManifestStore", "InMemoryManifestStore"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ManifestStore(Protocol):
|
|
43
|
+
"""Minimal persistence contract for checkpoint manifests.
|
|
44
|
+
|
|
45
|
+
Semantics (identical in spirit to `LeaseStore`):
|
|
46
|
+
- `add` registers a NEW manifest (duplicate manifest_id → ValueError);
|
|
47
|
+
- `save` persists the current state of an already-added manifest
|
|
48
|
+
(validation upgrades/quarantine); no-op for in-memory stores whose
|
|
49
|
+
cache holds live references;
|
|
50
|
+
- `get`/`all` read from the cache — `all(scope)` filters by the
|
|
51
|
+
catalog's scope key (the service uses `f"{job_id}::{task_id}"`) and
|
|
52
|
+
MUST preserve insertion order (selection tie-breaks depend on it).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def add(self, manifest: CheckpointManifest) -> None: ...
|
|
56
|
+
|
|
57
|
+
def save(self, manifest: CheckpointManifest) -> None: ...
|
|
58
|
+
|
|
59
|
+
def get(self, manifest_id: str) -> CheckpointManifest | None: ...
|
|
60
|
+
|
|
61
|
+
def all(self, scope: str | None = None) -> list[CheckpointManifest]: ...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class InMemoryManifestStore:
|
|
65
|
+
"""Reference implementation: live references, insertion-ordered,
|
|
66
|
+
volatile. This is functionally what the catalog does today — extracted
|
|
67
|
+
behind the seam so the SQLite store is a drop-in."""
|
|
68
|
+
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
self._manifests: dict[str, CheckpointManifest] = {}
|
|
71
|
+
|
|
72
|
+
def add(self, manifest: CheckpointManifest) -> None:
|
|
73
|
+
if manifest.manifest_id in self._manifests:
|
|
74
|
+
raise ValueError(f"manifest {manifest.manifest_id} already exists")
|
|
75
|
+
self._manifests[manifest.manifest_id] = manifest
|
|
76
|
+
|
|
77
|
+
def save(self, manifest: CheckpointManifest) -> None:
|
|
78
|
+
pass # live references — mutations already visible
|
|
79
|
+
|
|
80
|
+
def get(self, manifest_id: str) -> CheckpointManifest | None:
|
|
81
|
+
return self._manifests.get(manifest_id)
|
|
82
|
+
|
|
83
|
+
def all(self, scope: str | None = None) -> list[CheckpointManifest]:
|
|
84
|
+
return [
|
|
85
|
+
m for m in self._manifests.values() if scope is None or m.job_id == scope
|
|
86
|
+
]
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""Framework adapters: each builds CommandWorkloads from one framework's
|
|
2
|
+
LAUNCH AND CHECKPOINT CONVENTIONS — never its model code, never a
|
|
3
|
+
module-level framework import (four-axes rule). Import the submodule you
|
|
4
|
+
need: `from flashruntime.integrations import sklearn, pytorch, huggingface`.
|
|
5
|
+
"""
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Hugging Face adapter: HF Trainer already wraps DDP/FSDP internally when
|
|
2
|
+
launched by torchrun, so launching is the pytorch adapter's job. What HF
|
|
3
|
+
adds is its callback seam — `flashruntime_callback()` commits Trainer
|
|
4
|
+
checkpoints as verified manifests. transformers is imported only inside
|
|
5
|
+
that factory, in the user's training process.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from flashruntime.checkpoint.local import latest_valid_manifest
|
|
14
|
+
from flashruntime.integrations.pytorch import ddp
|
|
15
|
+
from flashruntime.workloads.command import CommandWorkload
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def trainer(
|
|
19
|
+
script: str, *, source: str = ".", nproc_per_node: int = 1, script_args: str = ""
|
|
20
|
+
) -> CommandWorkload:
|
|
21
|
+
return ddp(script, source=source, nproc_per_node=nproc_per_node, script_args=script_args)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def latest_checkpoint(output_dir: str | Path) -> str | None:
|
|
25
|
+
"""Newest Trainer checkpoint dir with a VALID manifest — pass as
|
|
26
|
+
`trainer.train(resume_from_checkpoint=...)`. None means fresh start."""
|
|
27
|
+
manifest = latest_valid_manifest(Path(output_dir), pattern="checkpoint-*")
|
|
28
|
+
return None if manifest is None else manifest.storage_prefix
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def flashruntime_callback():
|
|
32
|
+
"""Build the TrainerCallback (transformers import paid here, in the
|
|
33
|
+
user's process only): on_save commits a manifest, on_log relays metrics."""
|
|
34
|
+
from transformers import TrainerCallback # noqa: PLC0415 — user process only
|
|
35
|
+
|
|
36
|
+
from flashruntime.checkpoint.local import write_manifest
|
|
37
|
+
|
|
38
|
+
class FlashRuntimeCallback(TrainerCallback):
|
|
39
|
+
def on_save(self, args, state, control, **kwargs):
|
|
40
|
+
if state.is_world_process_zero:
|
|
41
|
+
step_dir = Path(args.output_dir) / f"checkpoint-{state.global_step}"
|
|
42
|
+
if step_dir.is_dir():
|
|
43
|
+
write_manifest(
|
|
44
|
+
step_dir,
|
|
45
|
+
job_id=os.environ.get("FLASHML_JOB_ID", "local"),
|
|
46
|
+
attempt_id=os.environ.get("FLASHML_ATTEMPT_ID", "local"),
|
|
47
|
+
step=state.global_step,
|
|
48
|
+
framework="transformers",
|
|
49
|
+
)
|
|
50
|
+
return control
|
|
51
|
+
|
|
52
|
+
def on_log(self, args, state, control, logs=None, **kwargs):
|
|
53
|
+
if logs and state.is_world_process_zero:
|
|
54
|
+
from flashruntime.torch import log_metrics
|
|
55
|
+
|
|
56
|
+
log_metrics({**logs, "step": state.global_step})
|
|
57
|
+
return control
|
|
58
|
+
|
|
59
|
+
return FlashRuntimeCallback()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""PyTorch adapter: launch conventions only. torchrun starts N processes
|
|
2
|
+
and hands each RANK/WORLD_SIZE — the user's code (or
|
|
3
|
+
flashruntime.torch.prepare) wires DDP from there. No torch import here.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import shlex
|
|
9
|
+
|
|
10
|
+
from flashruntime.protocol.plan_v1alpha1 import CheckpointPolicy
|
|
11
|
+
from flashruntime.workloads.command import CommandWorkload, OutputSpec, Source
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ddp(
|
|
15
|
+
script: str,
|
|
16
|
+
*,
|
|
17
|
+
source: str = ".",
|
|
18
|
+
nproc_per_node: int = 2,
|
|
19
|
+
nnodes: int = 1,
|
|
20
|
+
script_args: str = "",
|
|
21
|
+
env: dict[str, str] | None = None,
|
|
22
|
+
) -> CommandWorkload:
|
|
23
|
+
if nnodes > 1:
|
|
24
|
+
raise NotImplementedError(
|
|
25
|
+
"multi-node rendezvous is a launcher concern — later slice (spec §10); "
|
|
26
|
+
"--standalone below is single-node by definition"
|
|
27
|
+
)
|
|
28
|
+
command = [
|
|
29
|
+
"torchrun",
|
|
30
|
+
f"--nproc-per-node={nproc_per_node}",
|
|
31
|
+
f"--nnodes={nnodes}",
|
|
32
|
+
"--standalone",
|
|
33
|
+
# Single-node by definition (nnodes > 1 raised above), so pin the
|
|
34
|
+
# advertised rendezvous address. Without this torchrun advertises
|
|
35
|
+
# socket.getfqdn(), which on some macOS DNS setups returns an
|
|
36
|
+
# unresolvable ip6.arpa name — workers then retry DNS forever and
|
|
37
|
+
# the run hangs before spawning a single process.
|
|
38
|
+
"--local-addr=127.0.0.1",
|
|
39
|
+
script,
|
|
40
|
+
*shlex.split(script_args),
|
|
41
|
+
]
|
|
42
|
+
return CommandWorkload(
|
|
43
|
+
command=command,
|
|
44
|
+
source=Source(path=source),
|
|
45
|
+
env=env or {},
|
|
46
|
+
mode="coordinated",
|
|
47
|
+
checkpoint=CheckpointPolicy(
|
|
48
|
+
backend="local_manifest",
|
|
49
|
+
note="flashruntime.torch.checkpoint: parts-first/manifest-last under FLASHML_CKPT_DIR",
|
|
50
|
+
),
|
|
51
|
+
outputs=OutputSpec(collect=["metrics.json"]),
|
|
52
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""sklearn adapter: distribute across runs, never inside .fit().
|
|
2
|
+
|
|
3
|
+
The contract with the user's script is pure convention: CLI flags in,
|
|
4
|
+
metrics.json out. No sklearn import here — the script owns the estimator.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import itertools
|
|
10
|
+
|
|
11
|
+
from flashruntime.workloads.command import CommandWorkload, OutputSpec, Source
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def sweep(
|
|
15
|
+
script: str,
|
|
16
|
+
task_params: list[dict],
|
|
17
|
+
*,
|
|
18
|
+
source: str = ".",
|
|
19
|
+
metric: str = "accuracy_mean",
|
|
20
|
+
maximize: bool = True,
|
|
21
|
+
python: str = "python",
|
|
22
|
+
) -> CommandWorkload:
|
|
23
|
+
"""One independent task per params dict. Every dict must carry every
|
|
24
|
+
key (the CLI flags are built from the union)."""
|
|
25
|
+
keys = sorted({k for p in task_params for k in p})
|
|
26
|
+
command = [python, script]
|
|
27
|
+
for key in keys:
|
|
28
|
+
command += [f"--{key}", "{" + key + "}"]
|
|
29
|
+
return CommandWorkload(
|
|
30
|
+
command=command,
|
|
31
|
+
source=Source(path=source),
|
|
32
|
+
task_params=task_params,
|
|
33
|
+
mode="independent_tasks",
|
|
34
|
+
outputs=OutputSpec(collect=["metrics.json"], primary_metric=metric, maximize=maximize),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def hpo(script: str, grid: dict[str, list], **kwargs) -> CommandWorkload:
|
|
39
|
+
"""Cartesian grid search: {"model": ["logreg","rf"], "C": [0.1, 1]} → 4 trials."""
|
|
40
|
+
keys = sorted(grid)
|
|
41
|
+
trials = [dict(zip(keys, combo)) for combo in itertools.product(*(grid[k] for k in keys))]
|
|
42
|
+
return sweep(script, trials, **kwargs)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Launchers: start a compiled `LaunchSpec` and watch it run.
|
|
2
|
+
|
|
3
|
+
The launcher owns everything environment-shaped that compilation must not
|
|
4
|
+
touch: resolving rendezvous addresses/ports, materializing `spec.files`
|
|
5
|
+
into a workdir, injecting credentials, spawning the processes (locally,
|
|
6
|
+
via torchrun, on Kubernetes, on Slurm), and reporting their fate.
|
|
7
|
+
|
|
8
|
+
Division of labor (the four-axes rule, HANDBOOK §2.1):
|
|
9
|
+
compiler = *what* to run · launcher = *how processes start* ·
|
|
10
|
+
provider = *where machines come from* · recipe = *what the user's code is*.
|
|
11
|
+
|
|
12
|
+
Failure semantics: a launcher NEVER retries or recovers — it reports.
|
|
13
|
+
Recovery is the coordinator's job (`recovery.decide()` chooses
|
|
14
|
+
RESTART_GROUP etc.); a launcher that silently relaunched would corrupt the
|
|
15
|
+
ledger's story and double-spend the user's budget.
|
|
16
|
+
|
|
17
|
+
Status: interface complete (final surface); `TorchrunLauncher` is the
|
|
18
|
+
first concrete (SPRINT_PLAN Week 2), then kubernetes/slurm/skypilot per
|
|
19
|
+
demand — see FLASHRUNTIME_EVALUATION §J for each substrate's role.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import enum
|
|
25
|
+
from abc import ABC, abstractmethod
|
|
26
|
+
from typing import ClassVar
|
|
27
|
+
|
|
28
|
+
from flashruntime.strategies import LaunchSpec
|
|
29
|
+
|
|
30
|
+
__all__ = ["LaunchState", "LaunchHandle", "Launcher", "LaunchError"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LaunchError(Exception):
|
|
34
|
+
"""Launch could not start (preflight or spawn failure). Once processes
|
|
35
|
+
are running, failures are reported through `LaunchHandle.poll`, never
|
|
36
|
+
raised — the coordinator must always get a story, not a stack trace."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LaunchState(str, enum.Enum):
|
|
40
|
+
"""Coarse lifecycle every substrate can map onto (K8s pod phases,
|
|
41
|
+
process exit codes, Slurm job states all reduce to these five)."""
|
|
42
|
+
|
|
43
|
+
PENDING = "PENDING" # accepted, processes not yet running
|
|
44
|
+
RUNNING = "RUNNING" # at least one worker alive, none failed
|
|
45
|
+
SUCCEEDED = "SUCCEEDED" # all workers exited 0
|
|
46
|
+
FAILED = "FAILED" # any worker exited non-zero / infra killed it
|
|
47
|
+
CANCELLED = "CANCELLED" # cancel() honored
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def terminal(self) -> bool:
|
|
51
|
+
return self in (LaunchState.SUCCEEDED, LaunchState.FAILED, LaunchState.CANCELLED)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class LaunchHandle(ABC):
|
|
55
|
+
"""A live (or finished) launch. Handles are cheap views over substrate
|
|
56
|
+
state — polling must be side-effect free and safe to call forever."""
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def poll(self) -> LaunchState:
|
|
60
|
+
"""Current state. Must be non-blocking and idempotent. After a
|
|
61
|
+
terminal state is returned once, every later call returns the same
|
|
62
|
+
terminal state (substrates that garbage-collect finished jobs must
|
|
63
|
+
cache the outcome)."""
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def cancel(self) -> None:
|
|
67
|
+
"""Best-effort stop: idempotent, non-blocking, never raises on an
|
|
68
|
+
already-terminal launch. The next `poll()` after a successful
|
|
69
|
+
cancel eventually reports CANCELLED (grace periods allowed)."""
|
|
70
|
+
|
|
71
|
+
def wait(self, timeout_seconds: float | None = None) -> LaunchState:
|
|
72
|
+
"""Convenience: poll until terminal or timeout; returns the last
|
|
73
|
+
observed state (callers check `.terminal`). Default implementation
|
|
74
|
+
polls at 1 s; substrates with native wait primitives override."""
|
|
75
|
+
import time
|
|
76
|
+
|
|
77
|
+
deadline = None if timeout_seconds is None else time.monotonic() + timeout_seconds
|
|
78
|
+
while True:
|
|
79
|
+
state = self.poll()
|
|
80
|
+
if state.terminal or (deadline and time.monotonic() >= deadline):
|
|
81
|
+
return state
|
|
82
|
+
time.sleep(1.0)
|
|
83
|
+
|
|
84
|
+
def logs(self, tail_lines: int = 200) -> str:
|
|
85
|
+
"""Recent combined output for the ledger/dashboard. Optional —
|
|
86
|
+
default is an honest empty string, never fabricated content."""
|
|
87
|
+
return ""
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def execution_id(self) -> str:
|
|
91
|
+
"""Substrate-native identifier (pid list, RayJob name, Slurm job
|
|
92
|
+
id) for the ledger's `runtime_execution_id`. Default: unnamed."""
|
|
93
|
+
return ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Launcher(ABC):
|
|
97
|
+
"""One way of starting process groups.
|
|
98
|
+
|
|
99
|
+
Contract:
|
|
100
|
+
- `healthy()` is the ONLY place environment preflight lives (binary on
|
|
101
|
+
PATH, cluster reachable, quota available). Compilers stay pure.
|
|
102
|
+
- `launch()` must either raise `LaunchError` before anything started,
|
|
103
|
+
or return a handle — never leave orphaned half-started state without
|
|
104
|
+
a handle that can cancel it.
|
|
105
|
+
- Secrets/credentials are injected here (from the launcher's own
|
|
106
|
+
config), merged UNDER `spec.env` so a plan can never override
|
|
107
|
+
security-relevant variables.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
#: Name matching `StrategyPlan.launcher` values
|
|
111
|
+
#: ("local", "torchrun", "kubernetes", "slurm", "skypilot",
|
|
112
|
+
#: "flashruntime-leases").
|
|
113
|
+
name: ClassVar[str]
|
|
114
|
+
|
|
115
|
+
def healthy(self) -> tuple[bool, str]:
|
|
116
|
+
"""Preflight: can this launcher launch right now? Returns
|
|
117
|
+
(ok, human-readable reason). Called before spending money; a False
|
|
118
|
+
here routes the coordinator to PAUSE, not to a doomed launch."""
|
|
119
|
+
return True, "no preflight implemented"
|
|
120
|
+
|
|
121
|
+
@abstractmethod
|
|
122
|
+
def launch(self, spec: LaunchSpec, job_id: str, attempt_id: str) -> LaunchHandle:
|
|
123
|
+
"""Start the group described by `spec`.
|
|
124
|
+
|
|
125
|
+
Inputs: the compiled spec; job/attempt ids for labeling substrate
|
|
126
|
+
resources (the flashml.dev/* label convention) so operators can
|
|
127
|
+
trace any pod/process back to its ledger entry.
|
|
128
|
+
Output: a `LaunchHandle` (see its contract).
|
|
129
|
+
Raises: `LaunchError` only for failures *before* anything runs.
|
|
130
|
+
"""
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""LocalProcessLauncher — the first concrete Launcher.
|
|
2
|
+
|
|
3
|
+
Runs a LaunchSpec as one OS process on this machine: cwd from
|
|
4
|
+
`workdir_hint`, the caller's environment merged UNDER `spec.env` and the
|
|
5
|
+
FlashRuntime contract variables, stdout+stderr captured to a log file in
|
|
6
|
+
the attempt's output directory. This is Mode 0 execution and the substrate
|
|
7
|
+
under `flash.submit(...)`'s local path.
|
|
8
|
+
|
|
9
|
+
Contract variables exported to the child (opt-in for user code):
|
|
10
|
+
FLASHML_OUTPUT_DIR — per-attempt scratch/output directory
|
|
11
|
+
FLASHML_CKPT_DIR — per-JOB checkpoint tree (attempts share it, so a
|
|
12
|
+
restarted attempt can restore its predecessor's
|
|
13
|
+
manifests — the resume path depends on this)
|
|
14
|
+
FLASHML_JOB_ID / FLASHML_ATTEMPT_ID
|
|
15
|
+
|
|
16
|
+
Honors the Launcher contract: LaunchError only before a process exists;
|
|
17
|
+
after that, every failure is reported through poll(), never raised — and
|
|
18
|
+
this launcher never retries (recovery belongs to the coordinator).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import subprocess
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from flashruntime.launchers import Launcher, LaunchError, LaunchHandle, LaunchState
|
|
28
|
+
from flashruntime.strategies import LaunchSpec
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LocalLaunchHandle(LaunchHandle):
|
|
32
|
+
def __init__(self, proc: subprocess.Popen, log_path: Path, output_dir: Path):
|
|
33
|
+
self._proc = proc
|
|
34
|
+
self._log_path = log_path
|
|
35
|
+
self.output_dir = output_dir
|
|
36
|
+
self._final: LaunchState | None = None
|
|
37
|
+
self._cancelled = False
|
|
38
|
+
|
|
39
|
+
def poll(self) -> LaunchState:
|
|
40
|
+
if self._final is not None:
|
|
41
|
+
return self._final
|
|
42
|
+
code = self._proc.poll()
|
|
43
|
+
if code is None:
|
|
44
|
+
return LaunchState.RUNNING
|
|
45
|
+
if self._cancelled:
|
|
46
|
+
self._final = LaunchState.CANCELLED
|
|
47
|
+
else:
|
|
48
|
+
self._final = LaunchState.SUCCEEDED if code == 0 else LaunchState.FAILED
|
|
49
|
+
return self._final
|
|
50
|
+
|
|
51
|
+
def cancel(self) -> None:
|
|
52
|
+
if self.poll().terminal:
|
|
53
|
+
return
|
|
54
|
+
self._cancelled = True
|
|
55
|
+
self._proc.terminate()
|
|
56
|
+
|
|
57
|
+
def wait(self, timeout_seconds: float | None = None) -> LaunchState:
|
|
58
|
+
# native wait beats the ABC's 1 s polling loop
|
|
59
|
+
try:
|
|
60
|
+
self._proc.wait(timeout=timeout_seconds)
|
|
61
|
+
except subprocess.TimeoutExpired:
|
|
62
|
+
pass
|
|
63
|
+
return self.poll()
|
|
64
|
+
|
|
65
|
+
def logs(self, tail_lines: int = 200) -> str:
|
|
66
|
+
if not self._log_path.is_file():
|
|
67
|
+
return ""
|
|
68
|
+
lines = self._log_path.read_text(errors="replace").splitlines()
|
|
69
|
+
return "\n".join(lines[-tail_lines:])
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def execution_id(self) -> str:
|
|
73
|
+
return str(self._proc.pid)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def exit_code(self) -> int | None:
|
|
77
|
+
"""The child's raw OS return code once terminal (None while running).
|
|
78
|
+
The coarse LaunchState collapses every nonzero exit to FAILED, but
|
|
79
|
+
recovery needs the number: 137/-9 (signal death) and a bare SystemExit
|
|
80
|
+
classify differently from an ImportError traceback. Populated by the
|
|
81
|
+
preceding poll()/wait(); a negative value is a POSIX signal number."""
|
|
82
|
+
return self._proc.returncode
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class LocalProcessLauncher(Launcher):
|
|
86
|
+
name = "local"
|
|
87
|
+
|
|
88
|
+
def __init__(self, output_root: str | Path):
|
|
89
|
+
self._output_root = Path(output_root)
|
|
90
|
+
|
|
91
|
+
def launch(self, spec: LaunchSpec, job_id: str, attempt_id: str) -> LocalLaunchHandle:
|
|
92
|
+
workdir = Path(spec.workdir_hint or ".").expanduser()
|
|
93
|
+
if not spec.argv:
|
|
94
|
+
raise LaunchError("empty argv")
|
|
95
|
+
if not workdir.is_dir():
|
|
96
|
+
raise LaunchError(f"workdir does not exist: {workdir}")
|
|
97
|
+
outdir = self._output_root / job_id / attempt_id
|
|
98
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
for name, content in spec.files.items():
|
|
100
|
+
(outdir / name).write_text(content)
|
|
101
|
+
env = {
|
|
102
|
+
**os.environ,
|
|
103
|
+
**spec.env,
|
|
104
|
+
"FLASHML_OUTPUT_DIR": str(outdir),
|
|
105
|
+
"FLASHML_CKPT_DIR": str(self._output_root / job_id / "ckpt"),
|
|
106
|
+
"FLASHML_JOB_ID": job_id,
|
|
107
|
+
"FLASHML_ATTEMPT_ID": attempt_id,
|
|
108
|
+
}
|
|
109
|
+
log_path = outdir / "launcher.log"
|
|
110
|
+
# Popen dups the fd into the child, so the parent's handle can (and
|
|
111
|
+
# must) be closed immediately — leaving it open leaks a descriptor
|
|
112
|
+
# and trips ResourceWarning; logs() re-reads the file from disk.
|
|
113
|
+
log_file = open(log_path, "w")
|
|
114
|
+
try:
|
|
115
|
+
proc = subprocess.Popen(
|
|
116
|
+
spec.argv,
|
|
117
|
+
cwd=str(workdir),
|
|
118
|
+
env=env,
|
|
119
|
+
stdout=log_file,
|
|
120
|
+
stderr=subprocess.STDOUT,
|
|
121
|
+
)
|
|
122
|
+
except OSError as exc:
|
|
123
|
+
raise LaunchError(f"failed to start {spec.argv[0]!r}: {exc}") from exc
|
|
124
|
+
finally:
|
|
125
|
+
log_file.close()
|
|
126
|
+
return LocalLaunchHandle(proc, log_path, outdir)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Task lease semantics: assignment, renewal via heartbeat, expiration.
|
|
2
|
+
|
|
3
|
+
A lease grants one node the right to execute a task attempt for a bounded
|
|
4
|
+
period. Missed heartbeats past the deadline expire the lease and the task
|
|
5
|
+
is reassigned. Only one attempt may ever commit — late duplicates are
|
|
6
|
+
rejected (and recorded).
|
|
7
|
+
|
|
8
|
+
Embeddable, pure-Python, no I/O:
|
|
9
|
+
|
|
10
|
+
from flashruntime.leases import LeaseManager
|
|
11
|
+
from flashruntime.protocol.v1alpha1 import TaskSpec
|
|
12
|
+
|
|
13
|
+
mgr = LeaseManager(on_event=print)
|
|
14
|
+
mgr.add_task(TaskSpec(task_id="t1", job_id="j1", commit_key="j1/t1"))
|
|
15
|
+
lease = mgr.claim(node_id="laptop-1")
|
|
16
|
+
... work ...
|
|
17
|
+
mgr.heartbeat(lease.lease_id)
|
|
18
|
+
mgr.complete(lease.lease_id, output_sha256="...")
|
|
19
|
+
|
|
20
|
+
The FlashRuntime service exposes this same manager over HTTP; flashnode's
|
|
21
|
+
device executor is its remote client.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from flashruntime.leases.manager import LeaseError, LeaseManager
|
|
25
|
+
from flashruntime.leases.store import InMemoryLeaseStore, LeaseStore, TaskRecord
|
|
26
|
+
|
|
27
|
+
__all__ = ["LeaseManager", "LeaseError", "LeaseStore", "InMemoryLeaseStore", "TaskRecord"]
|