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,91 @@
|
|
|
1
|
+
"""Deterministic failure classification from raw signals.
|
|
2
|
+
|
|
3
|
+
Turns what was *observed* (exit codes, heartbeat loss, NCCL messages, XID
|
|
4
|
+
events, storage errors) into one typed `FailureClass`. Rules are
|
|
5
|
+
precedence-ordered — the first matching class wins — and the order encodes
|
|
6
|
+
root-cause priority: systemic evidence (correlated incidents, control-plane
|
|
7
|
+
loss) beats node evidence beats process evidence, because a worker crash
|
|
8
|
+
*during* a correlated incident is the incident, not the worker.
|
|
9
|
+
|
|
10
|
+
No inference beyond the table. Ambiguity resolves to conservative classes
|
|
11
|
+
(UNKNOWN classifies, policy decides what UNKNOWN deserves) rather than
|
|
12
|
+
optimistic ones.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
from flashruntime.protocol.v1alpha1 import FailureClass
|
|
20
|
+
|
|
21
|
+
# Substrings that identify communication-layer failures in worker logs.
|
|
22
|
+
# [assumption: covers the common NCCL/Gloo surfaces; extend from ledger data]
|
|
23
|
+
_COMM_MARKERS = (
|
|
24
|
+
"nccl",
|
|
25
|
+
"rccl",
|
|
26
|
+
"gloo",
|
|
27
|
+
"rendezvous",
|
|
28
|
+
"allreduce",
|
|
29
|
+
"all_reduce",
|
|
30
|
+
"process group",
|
|
31
|
+
)
|
|
32
|
+
_STORAGE_MARKERS = ("s3", "oss", "minio", "object store", "storage timeout", "slowdown")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class FailureSignals:
|
|
37
|
+
"""The raw evidence available when something broke. All fields optional —
|
|
38
|
+
classification works from whatever was actually observed."""
|
|
39
|
+
|
|
40
|
+
exit_code: int | None = None
|
|
41
|
+
exit_deterministic: bool = False # same error at the same step on retry
|
|
42
|
+
heartbeat_lost: bool = False
|
|
43
|
+
node_unreachable: bool = False
|
|
44
|
+
xid_event: bool = False # NVIDIA XID / ECC / NVML health failure
|
|
45
|
+
driver_fault: bool = False
|
|
46
|
+
hash_mismatch: bool = False
|
|
47
|
+
validation_failed: bool = False
|
|
48
|
+
preemption_notice: bool = False
|
|
49
|
+
storage_errors: int = 0
|
|
50
|
+
control_plane_unreachable: bool = False
|
|
51
|
+
concurrent_failures: int = 1 # failures in the correlation window, incl. this one
|
|
52
|
+
message: str = ""
|
|
53
|
+
|
|
54
|
+
def _msg(self) -> str:
|
|
55
|
+
return self.message.lower()
|
|
56
|
+
|
|
57
|
+
def has_comm_marker(self) -> bool:
|
|
58
|
+
return any(m in self._msg() for m in _COMM_MARKERS)
|
|
59
|
+
|
|
60
|
+
def has_storage_marker(self) -> bool:
|
|
61
|
+
return any(m in self._msg() for m in _STORAGE_MARKERS)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# How many failures inside the correlation window turn individual failures
|
|
65
|
+
# into one systemic incident. [assumption: recalibrate from ledger data]
|
|
66
|
+
CORRELATED_THRESHOLD = 3
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def classify(s: FailureSignals) -> FailureClass:
|
|
70
|
+
"""Precedence-ordered classification. Systemic > node > process > app."""
|
|
71
|
+
if s.concurrent_failures >= CORRELATED_THRESHOLD:
|
|
72
|
+
return FailureClass.CORRELATED_INCIDENT
|
|
73
|
+
if s.control_plane_unreachable:
|
|
74
|
+
return FailureClass.CONTROL_PLANE_FAILURE
|
|
75
|
+
if s.preemption_notice:
|
|
76
|
+
return FailureClass.PREEMPTION
|
|
77
|
+
if s.xid_event or s.driver_fault:
|
|
78
|
+
return FailureClass.ACCELERATOR_FAILURE
|
|
79
|
+
if s.heartbeat_lost or s.node_unreachable:
|
|
80
|
+
return FailureClass.NODE_LOSS
|
|
81
|
+
if s.has_comm_marker():
|
|
82
|
+
return FailureClass.COMMUNICATION_ERROR
|
|
83
|
+
if s.storage_errors > 0 or s.has_storage_marker():
|
|
84
|
+
return FailureClass.STORAGE_TIMEOUT
|
|
85
|
+
if s.hash_mismatch or s.validation_failed:
|
|
86
|
+
return FailureClass.ARTIFACT_CORRUPTION
|
|
87
|
+
if s.exit_code is not None and s.exit_code != 0:
|
|
88
|
+
if s.exit_deterministic:
|
|
89
|
+
return FailureClass.APPLICATION_ERROR
|
|
90
|
+
return FailureClass.WORKER_CRASH
|
|
91
|
+
return FailureClass.UNKNOWN
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Placement policy: which task should a claiming node receive?
|
|
2
|
+
|
|
3
|
+
Today's live behavior is FIFO over PENDING tasks (`LeaseStore.next_pending`
|
|
4
|
+
insertion order) — honest and correct while tasks are CPU-sized and
|
|
5
|
+
homogeneous. `FifoPlacement` encodes exactly that behavior as the first
|
|
6
|
+
concrete policy, so wiring this interface into the claim path changes
|
|
7
|
+
nothing until a richer policy is chosen deliberately.
|
|
8
|
+
|
|
9
|
+
This package becomes load-bearing when payloads carry resource
|
|
10
|
+
requirements (vram_gb, min_cpus) and claims must filter by node
|
|
11
|
+
capability — research item R9 in `HANDBOOK.md` §7. The master report's §9
|
|
12
|
+
discipline applies: capability ("can it run this?"), reliability ("will it
|
|
13
|
+
finish?") and trust ("may this data go there?") stay SEPARATE assessments;
|
|
14
|
+
this interface handles capability filtering + preference ordering only.
|
|
15
|
+
Reliability scoring waits for ledger volume; trust tiers are cloud policy.
|
|
16
|
+
|
|
17
|
+
Integration point (when the time comes): `LeaseManager.claim` consults the
|
|
18
|
+
policy instead of calling `next_pending` directly — the store keeps
|
|
19
|
+
returning candidates in insertion order; the policy filters and picks.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from abc import ABC, abstractmethod
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from flashruntime.protocol.v1alpha1 import TaskSpec
|
|
28
|
+
|
|
29
|
+
__all__ = ["PlacementPolicy", "FifoPlacement", "IsolationAwarePlacement"]
|
|
30
|
+
|
|
31
|
+
#: A node as the policy sees it: the registry's view dict
|
|
32
|
+
#: ({"node_id", "capabilities": {...}, ...}). Kept as a mapping (not a
|
|
33
|
+
#: model) until the capability schema for placement stabilizes with R9.
|
|
34
|
+
NodeView = dict[str, Any]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PlacementPolicy(ABC):
|
|
38
|
+
"""Filter + order candidate tasks for one claiming node.
|
|
39
|
+
|
|
40
|
+
Contracts:
|
|
41
|
+
- `eligible` is a pure predicate — no I/O, no side effects; called
|
|
42
|
+
once per (task, node) per claim, so it must be cheap.
|
|
43
|
+
- `score` orders eligible tasks (higher = better for THIS node);
|
|
44
|
+
ties break by queue order (fairness/FIFO stays the default
|
|
45
|
+
tiebreak so starvation cannot be introduced accidentally).
|
|
46
|
+
- `choose` is a template method most policies should NOT override —
|
|
47
|
+
override `eligible`/`score` and keep the selection semantics shared.
|
|
48
|
+
- A policy must never *create* work or mutate tasks; it only selects.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def eligible(self, task: TaskSpec, node: NodeView) -> bool:
|
|
53
|
+
"""May this node run this task at all? (capability gate — e.g.
|
|
54
|
+
payload wants vram_gb the node lacks → False). Unknown/absent
|
|
55
|
+
requirements must default to True: fail-open on *placement*,
|
|
56
|
+
because the executor's allowlists still fail-closed on *safety*."""
|
|
57
|
+
|
|
58
|
+
def score(self, task: TaskSpec, node: NodeView) -> float:
|
|
59
|
+
"""Preference among eligible tasks for this node (higher wins).
|
|
60
|
+
Default 0.0 for all ⇒ pure FIFO. Examples later: prefer tasks
|
|
61
|
+
whose input artifacts this node already cached; prefer short
|
|
62
|
+
tasks for soon-to-drain nodes."""
|
|
63
|
+
return 0.0
|
|
64
|
+
|
|
65
|
+
def choose(self, pending: list[TaskSpec], node: NodeView) -> TaskSpec | None:
|
|
66
|
+
"""Select the task to lease to `node` from queue-ordered `pending`.
|
|
67
|
+
|
|
68
|
+
Semantics: filter by `eligible`, take the max `score`, break ties
|
|
69
|
+
by earliest queue position. Returns None when nothing is eligible
|
|
70
|
+
(the claim endpoint answers 204, exactly like an empty queue).
|
|
71
|
+
"""
|
|
72
|
+
best: TaskSpec | None = None
|
|
73
|
+
best_score = float("-inf")
|
|
74
|
+
for task in pending: # queue order ⇒ first max wins ties
|
|
75
|
+
if not self.eligible(task, node):
|
|
76
|
+
continue
|
|
77
|
+
s = self.score(task, node)
|
|
78
|
+
if s > best_score:
|
|
79
|
+
best, best_score = task, s
|
|
80
|
+
return best
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FifoPlacement(PlacementPolicy):
|
|
84
|
+
"""The current system, as a policy: everything eligible, no
|
|
85
|
+
preference, first-come-first-served. Wiring this in is a pure
|
|
86
|
+
refactor — behavior is bit-identical to `next_pending`."""
|
|
87
|
+
|
|
88
|
+
def eligible(self, task: TaskSpec, node: NodeView) -> bool:
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class IsolationAwarePlacement(PlacementPolicy):
|
|
93
|
+
"""FIFO plus the one fail-closed capability gate the isolation contract
|
|
94
|
+
requires: a task whose payload demands sandboxed execution may only go
|
|
95
|
+
to a node advertising `sandbox_capable` — an ABSENT capability counts as
|
|
96
|
+
NOT capable (security-relevant fields fail closed, AGENTS.md rule 3).
|
|
97
|
+
|
|
98
|
+
The gate requires *true booleans*; type-confused values fail closed:
|
|
99
|
+
|
|
100
|
+
- A node is capable only when `sandbox_capable is True`. A truthy
|
|
101
|
+
stand-in (the JSON string ``"false"``, ``1``, ``"yes"``) does NOT
|
|
102
|
+
count as capable.
|
|
103
|
+
- The task's own `allowFallback` waives the requirement only when it is
|
|
104
|
+
exactly `True`; any other value (including the string ``"false"``)
|
|
105
|
+
does not waive.
|
|
106
|
+
- The isolation payload must be a mapping. If it is present but not a
|
|
107
|
+
dict (e.g. the bare string ``"sandboxed"``), the task is ineligible
|
|
108
|
+
everywhere — fail closed without crashing the predicate.
|
|
109
|
+
- Only tiers `None` / ``""`` / ``"standard"`` run anywhere. ANY other
|
|
110
|
+
tier value (an unknown or mistyped literal like ``"Sandboxed"``) is
|
|
111
|
+
treated like ``"sandboxed"`` and requires capability — no silent
|
|
112
|
+
downgrade to unsandboxed placement.
|
|
113
|
+
|
|
114
|
+
A second, independent gate applies to tasks carrying an `argv` payload
|
|
115
|
+
(arbitrary user command lines): the claiming node must advertise
|
|
116
|
+
`argv_capable is True`. This is checked BEFORE the isolation block's
|
|
117
|
+
`allowFallback` waiver below, and the waiver does not apply to it — a
|
|
118
|
+
submitter setting `allowFallback: true` waives the sandbox-tier
|
|
119
|
+
requirement only, never the argv-runner requirement, or arbitrary argv
|
|
120
|
+
could land on a node with no argv runner at all.
|
|
121
|
+
|
|
122
|
+
A third gate applies to tasks carrying a `module` payload (the
|
|
123
|
+
"python -m <allowlisted module>" tier): the claiming node must not be
|
|
124
|
+
`module_capable is False`. Its polarity is deliberately the MIRROR of
|
|
125
|
+
the argv gate above, not a copy of it:
|
|
126
|
+
|
|
127
|
+
- `argv_capable` is fail-closed (`is True` required) because argv is a
|
|
128
|
+
*safety* boundary — placing argv on a node with no argv runner is a
|
|
129
|
+
security failure (ArgvDockerRunner's own payload check would refuse
|
|
130
|
+
it, but the whole point of placement is to not burn attempts getting
|
|
131
|
+
there).
|
|
132
|
+
- `module_capable` is fail-OPEN (`is False` excludes) because it is an
|
|
133
|
+
*availability* concern only — a module task misplaced on an
|
|
134
|
+
argv-only node wastes retry attempts, it never escapes a sandbox.
|
|
135
|
+
Defaulting to capable means an already-deployed node whose
|
|
136
|
+
registration predates this field (module_capable absent ⇒ None) —
|
|
137
|
+
or any node that simply never opted into an argv-only runner — keeps
|
|
138
|
+
receiving module work. Only a node that explicitly advertises
|
|
139
|
+
`module_capable: false` (an argv-only volunteer) is excluded.
|
|
140
|
+
|
|
141
|
+
Do NOT "harmonize" these two gates onto the same polarity — copying
|
|
142
|
+
the argv gate's `is True` pattern onto module_capable would silently
|
|
143
|
+
cut every already-deployed node off from module work the moment this
|
|
144
|
+
field shipped, without anyone having opted out of anything.
|
|
145
|
+
|
|
146
|
+
Everything genuinely standard keeps the fail-open placement default."""
|
|
147
|
+
|
|
148
|
+
def eligible(self, task: TaskSpec, node: NodeView) -> bool:
|
|
149
|
+
# Checked before the allowFallback waiver below: the waiver relaxes
|
|
150
|
+
# the sandbox-tier requirement, and must never be readable as
|
|
151
|
+
# permission to run argv on a node with no argv runner.
|
|
152
|
+
if "argv" in task.payload and node.get("argv_capable") is not True:
|
|
153
|
+
return False
|
|
154
|
+
# Availability gate, mirrored polarity from the argv gate above —
|
|
155
|
+
# see the class docstring. An argv-only volunteer poisons every
|
|
156
|
+
# module job in the pool otherwise: it claims, ArgvDockerRunner
|
|
157
|
+
# rejects the payload, the attempt fails, and the task requeues
|
|
158
|
+
# into the same node's path until attempts are exhausted.
|
|
159
|
+
if "module" in task.payload and node.get("module_capable") is False:
|
|
160
|
+
return False
|
|
161
|
+
isolation = task.payload.get("isolation")
|
|
162
|
+
if isolation is None:
|
|
163
|
+
return True # no isolation payload ⇒ standard, runs anywhere
|
|
164
|
+
if not isinstance(isolation, dict):
|
|
165
|
+
return False # type-confused payload ⇒ fail closed, no crash
|
|
166
|
+
if isolation.get("tier") in (None, "", "standard"):
|
|
167
|
+
return True # only the known non-isolated tiers run anywhere
|
|
168
|
+
if isolation.get("allowFallback") is True:
|
|
169
|
+
return True # explicit waiver — genuine boolean only
|
|
170
|
+
return node.get("sandbox_capable") is True # capable ⇒ genuine boolean only
|
flashruntime/sdk.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""flash.submit(): run a CommandWorkload locally and hand back a Run.
|
|
2
|
+
|
|
3
|
+
The execution model is compile → launch → wait → collect, once per Mode A
|
|
4
|
+
param set (mirroring the M0 engine's replay model). Sequential-by-design
|
|
5
|
+
keeps collection correct (a trial's outputs are copied out of the source
|
|
6
|
+
dir before the next trial can overwrite them). Service submission is a
|
|
7
|
+
different door: `workloads.command.to_jobspec()` POSTed to the coordinator.
|
|
8
|
+
|
|
9
|
+
`wait=True` (default) drives that loop inline and returns a finished Run —
|
|
10
|
+
byte-for-byte the old synchronous behavior. `wait=False` returns the Run
|
|
11
|
+
immediately and drives the loop on a daemon thread, so the caller can watch
|
|
12
|
+
it live. Either way the Run mirrors itself to `<output_dir>/run.json` after
|
|
13
|
+
every state change (the `viewer_v1` contract Task 6 renders from disk).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
import webbrowser
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from flashruntime.launchers import LaunchState
|
|
29
|
+
from flashruntime.launchers.local import LocalProcessLauncher
|
|
30
|
+
from flashruntime.monitor import ResourceSampler
|
|
31
|
+
from flashruntime.protocol.v1alpha1 import RecoveryActionType
|
|
32
|
+
from flashruntime.recovery import classify, decide
|
|
33
|
+
from flashruntime.recovery.signals import from_local_launch
|
|
34
|
+
from flashruntime.strategies.command import compile_workload
|
|
35
|
+
from flashruntime.workloads.command import CommandWorkload
|
|
36
|
+
|
|
37
|
+
_JOB_ID = "local" # deterministic: rerunning with the same output_dir resumes checkpoints
|
|
38
|
+
|
|
39
|
+
# Viewers opened by watch=True. A RunViewerServer runs on a daemon thread, so
|
|
40
|
+
# it never blocks interpreter exit; we hold a reference here so it survives for
|
|
41
|
+
# the whole process lifetime (a wait=True submit returns before the human is
|
|
42
|
+
# done looking at the page, and the server must outlive that return).
|
|
43
|
+
_VIEWERS: list = []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Run:
|
|
47
|
+
"""Result handle for one submit().
|
|
48
|
+
|
|
49
|
+
Lock discipline (stated once, here): a `wait=False` submit drives the
|
|
50
|
+
launch loop on a daemon thread while the caller reads state/events live.
|
|
51
|
+
A single `threading.Lock` (`_lock`) guards the live-read fields
|
|
52
|
+
(`state`, `finished_at`, `_events`, `_attempts`) *and* the run.json
|
|
53
|
+
write, so the file is always serialized from a consistent snapshot and
|
|
54
|
+
a concurrent reader never observes a half-updated Run. `trials`,
|
|
55
|
+
`artifacts`, and `_logs` are mutated only by the single driver thread
|
|
56
|
+
and serialized only under the lock — they need no lock of their own.
|
|
57
|
+
Readers take copies under the lock; the driver holds it only for the
|
|
58
|
+
brief mutate-then-persist critical section. `_done` (a `threading.Event`)
|
|
59
|
+
is set exactly once, on the terminal transition, so `wait()` blocks
|
|
60
|
+
without polling.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, workload: CommandWorkload, output_dir: Path, max_restarts: int = 0):
|
|
64
|
+
self.workload = workload
|
|
65
|
+
self.output_dir = Path(output_dir)
|
|
66
|
+
self.max_restarts = max_restarts # the real recovery budget the viewer reports
|
|
67
|
+
self.state: LaunchState = LaunchState.PENDING
|
|
68
|
+
self.trials: list[dict] = []
|
|
69
|
+
self.artifacts: list[Path] = []
|
|
70
|
+
self.viewer_url: str | None = None # set when submit(watch=True) opens a live viewer
|
|
71
|
+
self.started_at: float = time.time()
|
|
72
|
+
self.finished_at: float | None = None
|
|
73
|
+
self._logs: list[str] = []
|
|
74
|
+
self._events: list[dict] = []
|
|
75
|
+
self._attempts: list[dict] = []
|
|
76
|
+
self._lock = threading.Lock()
|
|
77
|
+
self._done = threading.Event()
|
|
78
|
+
with self._lock: # materialize the PENDING record so run.json exists at once
|
|
79
|
+
self._write_run_json()
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def run_json_path(self) -> Path:
|
|
83
|
+
"""Path to the viewer_v1 document this Run mirrors itself to."""
|
|
84
|
+
return self.output_dir / "run.json"
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def events(self) -> list[dict]:
|
|
88
|
+
"""Snapshot copy of the append-only event log — safe to read while
|
|
89
|
+
the driver thread appends (a bare reference could tear)."""
|
|
90
|
+
with self._lock:
|
|
91
|
+
return [dict(e) for e in self._events]
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def attempts(self) -> list[dict]:
|
|
95
|
+
"""Snapshot copy of the per-launch attempt rows (see `events` for why
|
|
96
|
+
it copies)."""
|
|
97
|
+
with self._lock:
|
|
98
|
+
return [dict(a) for a in self._attempts]
|
|
99
|
+
|
|
100
|
+
def wait(self, timeout: float | None = None) -> LaunchState:
|
|
101
|
+
"""Block until the run is terminal (or `timeout` seconds elapse),
|
|
102
|
+
then return the current state. Event-based, not a poll loop: the
|
|
103
|
+
driver sets `_done` on the terminal transition, so a waiter wakes the
|
|
104
|
+
instant the run finishes with zero CPU spin — a polling loop would
|
|
105
|
+
burn cycles and add up-to-interval latency. Callers check
|
|
106
|
+
`.terminal` on the result (a timeout returns the last state seen)."""
|
|
107
|
+
self._done.wait(timeout)
|
|
108
|
+
return self.state
|
|
109
|
+
|
|
110
|
+
def record_event(self, type: str, message: str) -> None:
|
|
111
|
+
"""Append one event to the append-only log and re-persist run.json.
|
|
112
|
+
|
|
113
|
+
Reason: lifecycle/recovery decisions must reach the viewer (and Task
|
|
114
|
+
4's recovery wiring) the moment they happen. Takes `_lock` so the
|
|
115
|
+
append and the file write are one critical section."""
|
|
116
|
+
with self._lock:
|
|
117
|
+
self._events.append({"ts": time.time(), "type": type, "message": message})
|
|
118
|
+
self._write_run_json()
|
|
119
|
+
|
|
120
|
+
def _set_state(self, state: LaunchState) -> None:
|
|
121
|
+
"""Transition to `state` and re-persist run.json. On a terminal
|
|
122
|
+
state, stamp `finished_at` and release `wait()`ers (sets `_done`).
|
|
123
|
+
Under `_lock` so state + finished_at + write land atomically."""
|
|
124
|
+
with self._lock:
|
|
125
|
+
self.state = state
|
|
126
|
+
if state.terminal and self.finished_at is None:
|
|
127
|
+
self.finished_at = time.time()
|
|
128
|
+
self._write_run_json()
|
|
129
|
+
if state.terminal:
|
|
130
|
+
self._done.set()
|
|
131
|
+
|
|
132
|
+
def _add_attempt(self, attempt_id: str, job_id: str, handle, started_at: float) -> None:
|
|
133
|
+
"""Append a RUNNING attempt row (one per launch) and persist. `pid`
|
|
134
|
+
is the launcher's execution_id (a string), so the viewer can show
|
|
135
|
+
what is live."""
|
|
136
|
+
with self._lock:
|
|
137
|
+
self._attempts.append(
|
|
138
|
+
{
|
|
139
|
+
"attempt_id": attempt_id,
|
|
140
|
+
"job_id": job_id,
|
|
141
|
+
"state": LaunchState.RUNNING.value,
|
|
142
|
+
"pid": handle.execution_id,
|
|
143
|
+
"started_at": started_at,
|
|
144
|
+
"finished_at": None,
|
|
145
|
+
"output_dir": str(handle.output_dir),
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
self._write_run_json()
|
|
149
|
+
|
|
150
|
+
def _finish_attempt(self, attempt_id: str, state: LaunchState) -> None:
|
|
151
|
+
"""Settle an attempt's terminal state + finished_at and persist.
|
|
152
|
+
Matches the last row with this id, so restarts (Task 4) that append a
|
|
153
|
+
fresh row settle the right one."""
|
|
154
|
+
with self._lock:
|
|
155
|
+
for row in reversed(self._attempts):
|
|
156
|
+
if row["attempt_id"] == attempt_id:
|
|
157
|
+
row["state"] = state.value
|
|
158
|
+
row["finished_at"] = time.time()
|
|
159
|
+
break
|
|
160
|
+
self._write_run_json()
|
|
161
|
+
|
|
162
|
+
def _write_run_json(self) -> None:
|
|
163
|
+
"""Serialize the Run to <output_dir>/run.json atomically.
|
|
164
|
+
|
|
165
|
+
Write a temp file in the same directory, then `os.replace` it onto
|
|
166
|
+
the final path: os.replace is atomic on POSIX and Windows, so a
|
|
167
|
+
viewer reading concurrently sees either the whole old file or the
|
|
168
|
+
whole new one — never a torn, half-written document. Callers hold
|
|
169
|
+
`_lock`, so the snapshot serialized here is internally consistent."""
|
|
170
|
+
doc = {
|
|
171
|
+
"contract": "viewer_v1",
|
|
172
|
+
"workload": {
|
|
173
|
+
"command": self.workload.argv(),
|
|
174
|
+
"mode": self.workload.resolved_mode(),
|
|
175
|
+
"source": self.workload.source.path,
|
|
176
|
+
},
|
|
177
|
+
"state": self.state.value,
|
|
178
|
+
"started_at": self.started_at,
|
|
179
|
+
"finished_at": self.finished_at,
|
|
180
|
+
"max_restarts": self.max_restarts,
|
|
181
|
+
"attempts": [dict(a) for a in self._attempts],
|
|
182
|
+
"events": [dict(e) for e in self._events],
|
|
183
|
+
"trials": [dict(t) for t in self.trials],
|
|
184
|
+
}
|
|
185
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
tmp = self.output_dir / f".run.json.{os.getpid()}.tmp"
|
|
187
|
+
tmp.write_text(json.dumps(doc, indent=2))
|
|
188
|
+
os.replace(tmp, self.run_json_path)
|
|
189
|
+
|
|
190
|
+
def logs(self, tail_lines: int = 200) -> str:
|
|
191
|
+
return "\n".join("\n".join(self._logs).splitlines()[-tail_lines:])
|
|
192
|
+
|
|
193
|
+
def best_trial(self, metric: str | None = None, maximize: bool | None = None) -> dict | None:
|
|
194
|
+
"""Highest/lowest `metric` among trials that reported it. Defaults
|
|
195
|
+
come from the workload's OutputSpec (adapters set them)."""
|
|
196
|
+
metric = metric or self.workload.outputs.primary_metric
|
|
197
|
+
if maximize is None:
|
|
198
|
+
maximize = self.workload.outputs.maximize
|
|
199
|
+
if metric is None:
|
|
200
|
+
raise ValueError("no metric named: pass metric= or set outputs.primary_metric")
|
|
201
|
+
scored = [t for t in self.trials if metric in t]
|
|
202
|
+
if not scored:
|
|
203
|
+
return None
|
|
204
|
+
return max(scored, key=lambda t: t[metric]) if maximize else min(scored, key=lambda t: t[metric])
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _watch_enabled(watch: bool | None) -> bool:
|
|
208
|
+
"""Resolve the `watch` tri-state. Explicit True/False wins; None is auto —
|
|
209
|
+
on only at an interactive terminal AND outside CI. A pipeline or a piped
|
|
210
|
+
stdout must never open a browser or hold a server open, so auto stays off
|
|
211
|
+
there even though the run is identical."""
|
|
212
|
+
if watch is not None:
|
|
213
|
+
return watch
|
|
214
|
+
return sys.stdout.isatty() and os.environ.get("CI") is None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _open_viewer(run: Run) -> None:
|
|
218
|
+
"""Start a read-only RunViewerServer over the run's output dir, record its
|
|
219
|
+
URL on the Run, open a browser at it, and keep the server alive for the
|
|
220
|
+
process lifetime (`_VIEWERS`). Best-effort by design: the viewer is a
|
|
221
|
+
convenience layered on top of the run, never load-bearing — a headless box
|
|
222
|
+
with no browser, or a port that will not bind, must still run the job. So
|
|
223
|
+
every failure here is swallowed and the submit proceeds unwatched."""
|
|
224
|
+
try:
|
|
225
|
+
from flashruntime.viewer.server import RunViewerServer
|
|
226
|
+
|
|
227
|
+
server = RunViewerServer(run.output_dir)
|
|
228
|
+
url = server.start()
|
|
229
|
+
_VIEWERS.append(server) # hold a reference: daemon thread, freed at exit
|
|
230
|
+
run.viewer_url = url
|
|
231
|
+
print(f"viewer: {url}")
|
|
232
|
+
try:
|
|
233
|
+
webbrowser.open(url)
|
|
234
|
+
except Exception: # noqa: BLE001 — headless / no browser must not fail the run
|
|
235
|
+
pass
|
|
236
|
+
except Exception: # noqa: BLE001 — the viewer must never be able to fail a submit
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def submit(
|
|
241
|
+
workload: CommandWorkload,
|
|
242
|
+
output_dir: str | Path | None = None,
|
|
243
|
+
wait: bool = True,
|
|
244
|
+
max_restarts: int = 0,
|
|
245
|
+
watch: bool | None = None,
|
|
246
|
+
) -> Run:
|
|
247
|
+
"""Run a CommandWorkload locally. `max_restarts` (default 0 — no retry, the
|
|
248
|
+
old behavior) is the automatic fault-tolerance budget: a FAILED attempt is
|
|
249
|
+
classified and run against the versioned recovery policy, and unless the
|
|
250
|
+
failure is a deterministic application error (FAIL_JOB → fail fast) the
|
|
251
|
+
same spec is relaunched from the job-scoped checkpoint, up to this many
|
|
252
|
+
times. Applies to a single launch and per-trial in a fan-out.
|
|
253
|
+
|
|
254
|
+
`watch` opens the live run page (`flashruntime.viewer`) in a browser and
|
|
255
|
+
prints its URL (recorded on `Run.viewer_url`). None (default) auto-decides:
|
|
256
|
+
on at an interactive terminal, off in pipes/CI. The viewer server binds
|
|
257
|
+
127.0.0.1 on a daemon thread, so it works for both `wait=True` (it outlives
|
|
258
|
+
the synchronous return, so the page stays viewable) and `wait=False`."""
|
|
259
|
+
out_root = Path(output_dir) if output_dir else Path(tempfile.mkdtemp(prefix="flashruntime-run-"))
|
|
260
|
+
run = Run(workload, out_root, max_restarts=max_restarts)
|
|
261
|
+
launcher = LocalProcessLauncher(out_root)
|
|
262
|
+
|
|
263
|
+
# Open the viewer before driving so the page is live for the whole run —
|
|
264
|
+
# including a wait=False submit the caller watches from the first frame.
|
|
265
|
+
if _watch_enabled(watch):
|
|
266
|
+
_open_viewer(run)
|
|
267
|
+
|
|
268
|
+
fanout = workload.resolved_mode() == "independent_tasks" and workload.task_params
|
|
269
|
+
param_sets: list[dict | None] = list(workload.task_params) if fanout else [None]
|
|
270
|
+
|
|
271
|
+
if wait:
|
|
272
|
+
# inline drive == today's synchronous behavior (Run finished on return)
|
|
273
|
+
_drive(run, workload, launcher, param_sets)
|
|
274
|
+
else:
|
|
275
|
+
# daemon so an unwaited Run never blocks interpreter shutdown
|
|
276
|
+
threading.Thread(
|
|
277
|
+
target=_drive, args=(run, workload, launcher, param_sets), daemon=True
|
|
278
|
+
).start()
|
|
279
|
+
return run
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _drive(
|
|
283
|
+
run: Run, workload: CommandWorkload, launcher: LocalProcessLauncher, param_sets: list
|
|
284
|
+
) -> None:
|
|
285
|
+
"""The compile → launch → wait → collect loop, once per param set, then
|
|
286
|
+
settle the Run's terminal state. Extracted from submit() so wait=True can
|
|
287
|
+
call it inline (identical to the old synchronous path) and wait=False can
|
|
288
|
+
run it on a daemon thread.
|
|
289
|
+
|
|
290
|
+
Around each param set is the automatic-recovery loop (Task 4): on a FAILED
|
|
291
|
+
launch, translate the exit into FailureSignals, classify it, and run the
|
|
292
|
+
versioned policy — fail fast on a deterministic bug (FAIL_JOB), else
|
|
293
|
+
relaunch from the job-scoped checkpoint until the restart budget is spent.
|
|
294
|
+
`run.max_restarts == 0` reduces this to exactly one launch, the pre-Task-4
|
|
295
|
+
behavior."""
|
|
296
|
+
run._set_state(LaunchState.RUNNING)
|
|
297
|
+
source_dir = Path(workload.source.path).expanduser()
|
|
298
|
+
|
|
299
|
+
fanout = workload.resolved_mode() == "independent_tasks" and workload.task_params
|
|
300
|
+
# The policy's blast-radius axis: independent trials retry one task; a
|
|
301
|
+
# single/coordinated run restarts the whole (coordinated-training) group.
|
|
302
|
+
mode = "independent_tasks" if fanout else "coordinated_training"
|
|
303
|
+
|
|
304
|
+
states: list[LaunchState] = []
|
|
305
|
+
for i, params in enumerate(param_sets):
|
|
306
|
+
base_attempt = f"task-{i:03d}"
|
|
307
|
+
# Fan-out trials are DIFFERENT workloads (distinct params) — each needs
|
|
308
|
+
# its own checkpoint tree, or trial i could restore trial j's weights
|
|
309
|
+
# (FLASHML_CKPT_DIR is per job id). The single-workload path keeps the
|
|
310
|
+
# stable "local" id so a resubmit against the same output_dir resumes.
|
|
311
|
+
job_id = f"local-{i:03d}" if fanout else _JOB_ID
|
|
312
|
+
spec = compile_workload(workload, params)
|
|
313
|
+
|
|
314
|
+
final_state = LaunchState.FAILED
|
|
315
|
+
trial_metrics: dict | None = None
|
|
316
|
+
# restart 0 is the first try; 1..max_restarts are recovery attempts.
|
|
317
|
+
for restart in range(run.max_restarts + 1):
|
|
318
|
+
# Restart attempts append -r1/-r2… but keep the SAME job_id, so the
|
|
319
|
+
# launcher's job-scoped FLASHML_CKPT_DIR is shared across attempt
|
|
320
|
+
# names — that is the whole trick: the child's ft.prepare() resumes
|
|
321
|
+
# from the predecessor's newest valid manifest even though this
|
|
322
|
+
# attempt has a different id.
|
|
323
|
+
attempt_id = base_attempt if restart == 0 else f"{base_attempt}-r{restart}"
|
|
324
|
+
started_at = time.time()
|
|
325
|
+
handle = launcher.launch(spec, job_id, attempt_id)
|
|
326
|
+
run.record_event("LAUNCH_STARTED", f"{attempt_id} launched (pid {handle.execution_id})")
|
|
327
|
+
run._add_attempt(attempt_id, job_id, handle, started_at)
|
|
328
|
+
# Telemetry beside the attempt (machine + process tree → the run
|
|
329
|
+
# viewer's flow map). Best-effort like the viewer itself: a
|
|
330
|
+
# sampler that cannot start must never fail the run.
|
|
331
|
+
sampler = None
|
|
332
|
+
try:
|
|
333
|
+
sampler = ResourceSampler(handle.output_dir, int(handle.execution_id))
|
|
334
|
+
sampler.start()
|
|
335
|
+
except Exception: # noqa: BLE001 — observability never fails a run
|
|
336
|
+
sampler = None
|
|
337
|
+
try:
|
|
338
|
+
final_state = handle.wait()
|
|
339
|
+
finally:
|
|
340
|
+
if sampler is not None:
|
|
341
|
+
sampler.stop()
|
|
342
|
+
run._logs.append(f"--- {attempt_id} ({final_state.value}) ---\n{handle.logs()}")
|
|
343
|
+
run._finish_attempt(attempt_id, final_state)
|
|
344
|
+
|
|
345
|
+
collected = _collect(source_dir, workload.outputs.collect, handle.output_dir, since=started_at)
|
|
346
|
+
run.artifacts.extend(collected)
|
|
347
|
+
metrics_path = handle.output_dir / "metrics.json"
|
|
348
|
+
if metrics_path.is_file():
|
|
349
|
+
try:
|
|
350
|
+
metrics = json.loads(metrics_path.read_text())
|
|
351
|
+
except ValueError:
|
|
352
|
+
metrics = None
|
|
353
|
+
if isinstance(metrics, dict):
|
|
354
|
+
if params:
|
|
355
|
+
metrics.setdefault("params", params)
|
|
356
|
+
trial_metrics = metrics # the winning attempt's metrics (one trial per param set)
|
|
357
|
+
|
|
358
|
+
if final_state is LaunchState.SUCCEEDED:
|
|
359
|
+
break
|
|
360
|
+
|
|
361
|
+
# FAILED: consult the recovery policy. Signals → class → decision,
|
|
362
|
+
# both events recorded (with the decision's human reason) so the
|
|
363
|
+
# ledger/viewer can explain every retry.
|
|
364
|
+
exit_code = handle.exit_code
|
|
365
|
+
decision = decide(classify(from_local_launch(exit_code, handle.logs())), mode)
|
|
366
|
+
run.record_event(
|
|
367
|
+
"FAILURE_CLASSIFIED", f"{attempt_id}: {decision.failure_class.value} (exit {exit_code})"
|
|
368
|
+
)
|
|
369
|
+
run.record_event(
|
|
370
|
+
"RECOVERY_ACTION_SELECTED", f"{attempt_id}: {decision.action.value} — {decision.reason}"
|
|
371
|
+
)
|
|
372
|
+
# Fail fast on a deterministic application error — a retry only
|
|
373
|
+
# re-hits the same bug. Any other action means "retry", and the
|
|
374
|
+
# loop's range() enforces the budget, so it cannot spin forever.
|
|
375
|
+
if decision.action is RecoveryActionType.FAIL_JOB:
|
|
376
|
+
break
|
|
377
|
+
|
|
378
|
+
if trial_metrics is not None:
|
|
379
|
+
run.trials.append(trial_metrics)
|
|
380
|
+
states.append(final_state)
|
|
381
|
+
|
|
382
|
+
run._set_state(
|
|
383
|
+
LaunchState.SUCCEEDED
|
|
384
|
+
if states and all(s is LaunchState.SUCCEEDED for s in states)
|
|
385
|
+
else LaunchState.FAILED
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _collect(source_dir: Path, patterns: list[str], dest: Path, since: float) -> list[Path]:
|
|
390
|
+
"""Copy collect-globs from the script's cwd into the attempt's output
|
|
391
|
+
dir. `since` skips files older than this launch — a stale metrics.json
|
|
392
|
+
from a previous trial must never be credited to a failed one."""
|
|
393
|
+
out: list[Path] = []
|
|
394
|
+
for pattern in patterns:
|
|
395
|
+
for src in sorted(source_dir.glob(pattern)):
|
|
396
|
+
if not src.is_file() or src.stat().st_mtime < since:
|
|
397
|
+
continue
|
|
398
|
+
target = dest / src.relative_to(source_dir)
|
|
399
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
400
|
+
shutil.copy2(src, target)
|
|
401
|
+
out.append(target)
|
|
402
|
+
return out
|