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,135 @@
|
|
|
1
|
+
"""Workload recipes: everything one workload type needs, behind one interface.
|
|
2
|
+
|
|
3
|
+
Today each workload is wired by hand in three places: an expansion branch
|
|
4
|
+
in `service/modea.py`, a module allowlist entry (both ends), and a task
|
|
5
|
+
module in `flashml_workloads/`. A `WorkloadRecipe` bundles those into one
|
|
6
|
+
registrable object so adding a workload becomes: write the task module,
|
|
7
|
+
write the recipe, register it. The existing hand-rolled expansions
|
|
8
|
+
(`hyperparameter_search`, `sharded_kmeans`) are the reference behavior and
|
|
9
|
+
should migrate here without any wire-visible change.
|
|
10
|
+
|
|
11
|
+
A recipe spans three moments in a task's life:
|
|
12
|
+
submit-time — `validate_params` + `expand` (coordinator)
|
|
13
|
+
run-time — `task_module` executed by the agent (the §2.2 contract:
|
|
14
|
+
`python -m <module> --spec spec.json --out OUTDIR`)
|
|
15
|
+
commit-time — `validate_output` (coordinator; runs BEFORE acceptance,
|
|
16
|
+
alongside the sha256 check) and, for map/reduce shapes,
|
|
17
|
+
`reduce` (driver side)
|
|
18
|
+
|
|
19
|
+
Hard rules inherited from the system: the task module must appear on BOTH
|
|
20
|
+
allowlists (coordinator + agent) — a recipe self-declares it, and the
|
|
21
|
+
registry is what the allowlists will be generated from once migration
|
|
22
|
+
lands. Hugging Face code lives here and only here (four-axes rule): HF is
|
|
23
|
+
workload, never a backend.
|
|
24
|
+
|
|
25
|
+
Status: interface complete (final surface); first concrete recipe is
|
|
26
|
+
HF Trainer + PEFT LoRA (SPRINT_PLAN Days 8–10, design in ADR-0004-to-be).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from abc import ABC, abstractmethod
|
|
32
|
+
from typing import Any, ClassVar
|
|
33
|
+
|
|
34
|
+
from flashruntime.protocol.v1alpha1 import JobSpec, TaskSpec
|
|
35
|
+
|
|
36
|
+
__all__ = ["WorkloadRecipe", "register_recipe", "recipe_for"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class WorkloadRecipe(ABC):
|
|
40
|
+
"""One workload type, end to end.
|
|
41
|
+
|
|
42
|
+
Implementations must be *pure coordination logic*: no framework
|
|
43
|
+
imports at module import time (import torch/transformers inside the
|
|
44
|
+
task module, which runs on the agent — never in the coordinator's
|
|
45
|
+
process).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
#: The `JobSpec.spec.workload.type` value this recipe owns
|
|
49
|
+
#: (e.g. "hyperparameter_search", "sharded_kmeans", "lora_finetune").
|
|
50
|
+
kind: ClassVar[str]
|
|
51
|
+
|
|
52
|
+
#: The task module executed on agents — the value placed in every
|
|
53
|
+
#: payload's "module" field, and the entry both allowlists must carry.
|
|
54
|
+
task_module: ClassVar[str]
|
|
55
|
+
|
|
56
|
+
def validate_params(self, params: dict[str, Any]) -> list[str]:
|
|
57
|
+
"""Submit-time parameter validation.
|
|
58
|
+
|
|
59
|
+
Returns every problem found (empty list = valid); the service
|
|
60
|
+
turns a non-empty list into one 422 carrying all reasons — the
|
|
61
|
+
planner's surface-everything-at-once UX, applied to submission.
|
|
62
|
+
Default: accept anything (expand() remains the backstop).
|
|
63
|
+
"""
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def expand(self, job_id: str, spec: JobSpec) -> list[TaskSpec]:
|
|
68
|
+
"""Turn a JobSpec into independent TaskSpecs (Mode A expansion).
|
|
69
|
+
|
|
70
|
+
Requirements (mirror the existing expansions exactly):
|
|
71
|
+
- deterministic task_ids (`trial-000`-style) — retries and
|
|
72
|
+
dashboards depend on stable names;
|
|
73
|
+
- every payload carries: module, params, inputs (artifact:// URIs),
|
|
74
|
+
output_prefix, task_id, image, and `checkpoint` when the recipe
|
|
75
|
+
is checkpointable;
|
|
76
|
+
- commit_key = the metrics.json path under output_prefix (the
|
|
77
|
+
idempotency + validation anchor);
|
|
78
|
+
- honor spec.spec.retryPolicy.maxTaskAttempts and the workload's
|
|
79
|
+
lease_seconds parameter.
|
|
80
|
+
Raises ValueError (→ 422) when the spec cannot be expanded.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
def validate_output(self, metrics: dict[str, Any]) -> None:
|
|
85
|
+
"""Commit-time semantic validation of a task's metrics.json.
|
|
86
|
+
|
|
87
|
+
Runs on the coordinator after the sha256 check passes and before
|
|
88
|
+
the commit is accepted: raise (ValueError with a reason) to fail
|
|
89
|
+
the attempt — the task requeues, exactly like a hash mismatch.
|
|
90
|
+
This is the hook that catches \"the file is intact but the result
|
|
91
|
+
is nonsense\" (NaN loss, missing fields, accuracy > 1.0).
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def reduce(self, outputs: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
95
|
+
"""Combine per-task outputs into a job-level result (map/reduce
|
|
96
|
+
workloads: K-means partials → new centroids; HPO trials → best
|
|
97
|
+
config). Return None for workloads with no reduce step (default).
|
|
98
|
+
Runs driver/coordinator-side on already-validated outputs."""
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
def checkpointable(self) -> bool:
|
|
102
|
+
"""Whether tasks of this recipe write resumable checkpoints (turns
|
|
103
|
+
on the agent's checkpoint relay via the payload's `checkpoint`
|
|
104
|
+
key). Default False: stateless tasks retry from scratch, which is
|
|
105
|
+
cheaper than checkpointing for short work."""
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
def plan_hints(self) -> dict[str, Any]:
|
|
109
|
+
"""Optional coupling to the planner: static facts the estimators
|
|
110
|
+
may use (e.g. {'mode': 'independent_tasks'}). Keep it data-only —
|
|
111
|
+
the planner never calls recipe *code* (determinism + no-framework
|
|
112
|
+
rules)."""
|
|
113
|
+
return {}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_REGISTRY: dict[str, WorkloadRecipe] = {}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def register_recipe(recipe: WorkloadRecipe) -> None:
|
|
120
|
+
"""Register (or replace) the recipe for its `kind`. At service startup
|
|
121
|
+
the registered kinds become the expansion dispatch table, and the set
|
|
122
|
+
of `task_module`s becomes the coordinator-side allowlist — one source
|
|
123
|
+
of truth instead of today's two hand-maintained sets."""
|
|
124
|
+
_REGISTRY[recipe.kind] = recipe
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def recipe_for(kind: str) -> WorkloadRecipe:
|
|
128
|
+
"""Lookup by workload type; LookupError → the service's 422 listing
|
|
129
|
+
the supported kinds (same UX as the current ExpansionError)."""
|
|
130
|
+
try:
|
|
131
|
+
return _REGISTRY[kind]
|
|
132
|
+
except KeyError:
|
|
133
|
+
raise LookupError(
|
|
134
|
+
f"no recipe for workload type {kind!r} (registered: {sorted(_REGISTRY) or 'none'})"
|
|
135
|
+
)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""The generic command recipe: JobSpec{workload.type: "command"} → lease tasks.
|
|
2
|
+
|
|
3
|
+
The first concrete WorkloadRecipe. Payloads carry `argv` — the §2.2
|
|
4
|
+
executor contract generalized from `module` — plus the isolation
|
|
5
|
+
requirement the placement gate enforces fail-closed. Executing argv
|
|
6
|
+
payloads is flashnode's runner tier (cross-repo, versioned change); this
|
|
7
|
+
recipe defines the coordinator half of that contract. Until flashnode
|
|
8
|
+
ships it, command jobs expand and lease correctly but only argv-aware
|
|
9
|
+
executors can run them.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from typing import Any, ClassVar
|
|
16
|
+
|
|
17
|
+
from flashruntime.protocol.v1alpha1 import JobSpec, TaskSpec
|
|
18
|
+
from flashruntime.recipes import WorkloadRecipe, register_recipe
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CommandRecipe(WorkloadRecipe):
|
|
22
|
+
kind: ClassVar[str] = "command"
|
|
23
|
+
#: argv payloads name no task module — the isolation tier, not a module
|
|
24
|
+
#: allowlist, is the security control for this workload type.
|
|
25
|
+
task_module: ClassVar[str] = ""
|
|
26
|
+
|
|
27
|
+
def validate_params(self, params: dict[str, Any]) -> list[str]:
|
|
28
|
+
problems: list[str] = []
|
|
29
|
+
command = params.get("command")
|
|
30
|
+
if (
|
|
31
|
+
not command
|
|
32
|
+
or not isinstance(command, list)
|
|
33
|
+
or not all(isinstance(t, str) for t in command)
|
|
34
|
+
):
|
|
35
|
+
problems.append("'command' must be a non-empty argv list of strings")
|
|
36
|
+
for name, uri in (params.get("inputs") or {}).items():
|
|
37
|
+
if not str(uri).startswith("artifact://"):
|
|
38
|
+
problems.append(f"input '{name}' must be an artifact:// URI")
|
|
39
|
+
task_params = params.get("task_params")
|
|
40
|
+
if task_params is not None and (
|
|
41
|
+
not isinstance(task_params, list)
|
|
42
|
+
or not all(isinstance(p, dict) for p in task_params)
|
|
43
|
+
):
|
|
44
|
+
problems.append("'task_params' must be a list of objects")
|
|
45
|
+
problems.extend(self._unpack_problems(params))
|
|
46
|
+
return problems
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def _unpack_problems(params: dict[str, Any]) -> list[str]:
|
|
50
|
+
"""Validate `unpack_inputs` — the names flashnode may run an archive
|
|
51
|
+
extractor over.
|
|
52
|
+
|
|
53
|
+
This value decides which downloaded bytes get unpacked into a
|
|
54
|
+
directory tree on a *volunteer's* machine, so it is validated at
|
|
55
|
+
expansion time rather than forwarded as given. Naming an input that
|
|
56
|
+
was never declared is the interesting case: flashnode refuses it
|
|
57
|
+
too, but only after the task has been leased, claimed and half-run,
|
|
58
|
+
which surfaces to the submitter as a mysterious node-side failure
|
|
59
|
+
instead of the spec error it is. Catching it here fails on the
|
|
60
|
+
submitter's side, before anything is placed.
|
|
61
|
+
"""
|
|
62
|
+
unpack = params.get("unpack_inputs")
|
|
63
|
+
if unpack is None:
|
|
64
|
+
return []
|
|
65
|
+
if not isinstance(unpack, list) or not all(isinstance(n, str) for n in unpack):
|
|
66
|
+
return ["'unpack_inputs' must be a list of input names (strings)"]
|
|
67
|
+
problems: list[str] = []
|
|
68
|
+
duplicates = sorted({n for n in unpack if unpack.count(n) > 1})
|
|
69
|
+
if duplicates:
|
|
70
|
+
problems.append(
|
|
71
|
+
f"'unpack_inputs' names {duplicates} more than once — an input "
|
|
72
|
+
f"is unpacked at most once"
|
|
73
|
+
)
|
|
74
|
+
declared = set((params.get("inputs") or {}).keys())
|
|
75
|
+
unknown = sorted(set(unpack) - declared)
|
|
76
|
+
if unknown:
|
|
77
|
+
problems.append(
|
|
78
|
+
f"'unpack_inputs' names inputs that are not declared in "
|
|
79
|
+
f"'inputs': {unknown}"
|
|
80
|
+
)
|
|
81
|
+
return problems
|
|
82
|
+
|
|
83
|
+
def expand(self, job_id: str, spec: JobSpec) -> list[TaskSpec]:
|
|
84
|
+
isolation_spec = spec.spec.isolation
|
|
85
|
+
if isolation_spec.allowFallback:
|
|
86
|
+
# allowFallback waives the sandbox capability requirement at
|
|
87
|
+
# placement time. Honouring it for argv would let a submitter
|
|
88
|
+
# place arbitrary code on an unsandboxed node.
|
|
89
|
+
raise ValueError(
|
|
90
|
+
"command jobs may not set isolation.allowFallback — "
|
|
91
|
+
"argv execution is container-only"
|
|
92
|
+
)
|
|
93
|
+
if isolation_spec.tier != "sandboxed":
|
|
94
|
+
# Coordinator-side opt-in only: the operator running the pool
|
|
95
|
+
# decides, never the submitter.
|
|
96
|
+
if os.environ.get("FLASHML_ALLOW_UNSANDBOXED_ARGV") != "1":
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"command jobs require isolation.tier 'sandboxed', got "
|
|
99
|
+
f"{isolation_spec.tier!r} (set FLASHML_ALLOW_UNSANDBOXED_ARGV=1 "
|
|
100
|
+
f"on the coordinator to allow a trusted fleet)"
|
|
101
|
+
)
|
|
102
|
+
p = spec.spec.workload.parameters
|
|
103
|
+
problems = self.validate_params(p)
|
|
104
|
+
if problems:
|
|
105
|
+
raise ValueError("; ".join(problems))
|
|
106
|
+
|
|
107
|
+
param_sets: list[dict | None] = p.get("task_params") or [None]
|
|
108
|
+
env: dict[str, str] = dict(p.get("env") or {})
|
|
109
|
+
inputs = dict(p.get("inputs") or {})
|
|
110
|
+
isolation = {
|
|
111
|
+
"tier": spec.spec.isolation.tier,
|
|
112
|
+
"allowFallback": spec.spec.isolation.allowFallback,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
tasks: list[TaskSpec] = []
|
|
116
|
+
for i, params in enumerate(param_sets):
|
|
117
|
+
task_id = f"task-{i:03d}"
|
|
118
|
+
try:
|
|
119
|
+
argv = [t.format(**params) for t in p["command"]] if params else list(p["command"])
|
|
120
|
+
task_env = {
|
|
121
|
+
k: (v.format(**params) if params else v) for k, v in env.items()
|
|
122
|
+
}
|
|
123
|
+
except (KeyError, IndexError, ValueError) as exc:
|
|
124
|
+
# KeyError: a named {placeholder} with no matching param.
|
|
125
|
+
# IndexError/ValueError: an auto/positional field ({}, {0}) or
|
|
126
|
+
# a malformed brace str.format cannot fill from a params dict.
|
|
127
|
+
# All are user-input errors → ValueError (→422), never a 500.
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"task {i}: placeholder {exc} has no value in task_params[{i}]"
|
|
130
|
+
) from None
|
|
131
|
+
payload: dict[str, Any] = {
|
|
132
|
+
"argv": argv,
|
|
133
|
+
"env": task_env,
|
|
134
|
+
"inputs": inputs,
|
|
135
|
+
"output_prefix": f"jobs/{job_id}/{task_id}/",
|
|
136
|
+
"task_id": task_id,
|
|
137
|
+
"image": spec.spec.image.reference,
|
|
138
|
+
"isolation": isolation,
|
|
139
|
+
}
|
|
140
|
+
if p.get("checkpoint") is not None:
|
|
141
|
+
payload["checkpoint"] = p["checkpoint"]
|
|
142
|
+
if p.get("unpack_inputs") is not None:
|
|
143
|
+
# Absent stays absent, never an empty list: flashnode reads
|
|
144
|
+
# `payload.get("unpack_inputs")` and an omitted key is the
|
|
145
|
+
# path where every input keeps its plain-file behaviour byte
|
|
146
|
+
# for byte. Emitting `[]` would mean the same thing today but
|
|
147
|
+
# would stop exercising that path.
|
|
148
|
+
payload["unpack_inputs"] = list(p["unpack_inputs"])
|
|
149
|
+
tasks.append(
|
|
150
|
+
TaskSpec(
|
|
151
|
+
task_id=task_id,
|
|
152
|
+
job_id=job_id,
|
|
153
|
+
commit_key=f"jobs/{job_id}/{task_id}/metrics.json",
|
|
154
|
+
max_attempts=spec.spec.retryPolicy.maxTaskAttempts,
|
|
155
|
+
lease_seconds=float(p.get("lease_seconds", 60.0)),
|
|
156
|
+
payload=payload,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
return tasks
|
|
160
|
+
|
|
161
|
+
def validate_output(self, metrics: dict[str, Any]) -> None:
|
|
162
|
+
if not isinstance(metrics, dict):
|
|
163
|
+
raise ValueError("metrics.json must contain a JSON object")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
register_recipe(CommandRecipe())
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Failure taxonomy and the deterministic recovery policy.
|
|
2
|
+
|
|
3
|
+
Two pure functions and a versioned table:
|
|
4
|
+
|
|
5
|
+
from flashruntime.recovery import FailureSignals, classify, decide
|
|
6
|
+
|
|
7
|
+
failure = classify(FailureSignals(heartbeat_lost=True)) # NODE_LOSS
|
|
8
|
+
decision = decide(failure, mode="independent_tasks")
|
|
9
|
+
# → RETRY_TASK, cordon_node=True, policy_version=0.1.0, reason=...
|
|
10
|
+
|
|
11
|
+
`classify` turns raw observed signals (exit codes, heartbeat loss, NCCL
|
|
12
|
+
messages, XID events) into one typed FailureClass with precedence-ordered
|
|
13
|
+
rules. `decide` maps (FailureClass, execution mode) to one typed, logged
|
|
14
|
+
RecoveryDecision. No scoring, no learning, no agent: same failure + same
|
|
15
|
+
policy version ⇒ same action.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from flashruntime.recovery.policy import POLICY_VERSION, decide
|
|
19
|
+
from flashruntime.recovery.taxonomy import CORRELATED_THRESHOLD, FailureSignals, classify
|
|
20
|
+
|
|
21
|
+
__all__ = ["FailureSignals", "classify", "decide", "POLICY_VERSION", "CORRELATED_THRESHOLD"]
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""The versioned, deterministic recovery policy.
|
|
2
|
+
|
|
3
|
+
FailureClass × execution mode → one typed `RecoveryDecision`. The table *is*
|
|
4
|
+
the policy: no scoring, no learning, no agent — same failure + same policy
|
|
5
|
+
version ⇒ same action, always (ADR-0003 / master report guardrail: recovery
|
|
6
|
+
actions are typed, authorized, and logged).
|
|
7
|
+
|
|
8
|
+
The two execution modes matter because the same failure has different blast
|
|
9
|
+
radii: a worker crash in Mode A costs one task retry; in Mode B it stops the
|
|
10
|
+
whole group (NCCL state is not repairable in place — whole-group restart
|
|
11
|
+
from the latest valid checkpoint is the honest v1 promise).
|
|
12
|
+
|
|
13
|
+
Encoded rules worth naming:
|
|
14
|
+
- Deterministic application errors are never retried — fail fast, tell the
|
|
15
|
+
user. Burning capacity on a bug is the most expensive kind of "recovery".
|
|
16
|
+
- Storage outages pause rather than kill: compute waiting on dead storage
|
|
17
|
+
is wasted, but the job's state is intact — protect it and stop the burn.
|
|
18
|
+
- Correlated incidents freeze automation entirely. Retry storms during a
|
|
19
|
+
systemic incident are how orchestrators destroy trust; the policy's most
|
|
20
|
+
important action is knowing when to stop acting.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from flashruntime.protocol.v1alpha1 import (
|
|
26
|
+
FailureClass,
|
|
27
|
+
RecoveryActionType,
|
|
28
|
+
RecoveryDecision,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
POLICY_VERSION = "0.1.0"
|
|
32
|
+
|
|
33
|
+
Mode = str # "independent_tasks" | "coordinated_training"
|
|
34
|
+
|
|
35
|
+
# (action, scope, cordon_node, needs_checkpoint, reason)
|
|
36
|
+
_TABLE: dict[tuple[FailureClass, Mode], tuple[RecoveryActionType, str, bool, bool, str]] = {
|
|
37
|
+
# -- application / data: not infrastructure's fault ---------------------
|
|
38
|
+
(FailureClass.APPLICATION_ERROR, "independent_tasks"): (
|
|
39
|
+
RecoveryActionType.FAIL_JOB, "job", False, False,
|
|
40
|
+
"deterministic application error — retrying burns money on a bug",
|
|
41
|
+
),
|
|
42
|
+
(FailureClass.APPLICATION_ERROR, "coordinated_training"): (
|
|
43
|
+
RecoveryActionType.FAIL_JOB, "job", False, False,
|
|
44
|
+
"deterministic application error — retrying burns money on a bug",
|
|
45
|
+
),
|
|
46
|
+
(FailureClass.DATA_ERROR, "independent_tasks"): (
|
|
47
|
+
RecoveryActionType.RETRY_TASK, "task", False, False,
|
|
48
|
+
"data error on one shard — retry once elsewhere; quarantine on repeat",
|
|
49
|
+
),
|
|
50
|
+
(FailureClass.DATA_ERROR, "coordinated_training"): (
|
|
51
|
+
RecoveryActionType.FAIL_JOB, "job", False, False,
|
|
52
|
+
"data error in coordinated training — resume would repeat it",
|
|
53
|
+
),
|
|
54
|
+
# -- process / node -----------------------------------------------------
|
|
55
|
+
(FailureClass.WORKER_CRASH, "independent_tasks"): (
|
|
56
|
+
RecoveryActionType.RETRY_TASK, "task", False, False,
|
|
57
|
+
"non-deterministic worker crash — lease expires, task requeues",
|
|
58
|
+
),
|
|
59
|
+
(FailureClass.WORKER_CRASH, "coordinated_training"): (
|
|
60
|
+
RecoveryActionType.RESTART_GROUP, "group", False, True,
|
|
61
|
+
"a lost rank stops the group — restart all workers from latest valid checkpoint",
|
|
62
|
+
),
|
|
63
|
+
(FailureClass.NODE_LOSS, "independent_tasks"): (
|
|
64
|
+
RecoveryActionType.RETRY_TASK, "task", True, False,
|
|
65
|
+
"node gone — cordon it, expire its leases, requeue its tasks",
|
|
66
|
+
),
|
|
67
|
+
(FailureClass.NODE_LOSS, "coordinated_training"): (
|
|
68
|
+
RecoveryActionType.REPLACE_NODE, "group", True, True,
|
|
69
|
+
"node gone — acquire replacement capacity, then group restart from checkpoint",
|
|
70
|
+
),
|
|
71
|
+
(FailureClass.ACCELERATOR_FAILURE, "independent_tasks"): (
|
|
72
|
+
RecoveryActionType.RETRY_TASK, "node", True, False,
|
|
73
|
+
"GPU/driver fault — cordon the accelerator's node, retry elsewhere",
|
|
74
|
+
),
|
|
75
|
+
(FailureClass.ACCELERATOR_FAILURE, "coordinated_training"): (
|
|
76
|
+
RecoveryActionType.REPLACE_NODE, "group", True, True,
|
|
77
|
+
"GPU/driver fault — replace the node, group restart from checkpoint",
|
|
78
|
+
),
|
|
79
|
+
# -- communication / network -------------------------------------------
|
|
80
|
+
(FailureClass.COMMUNICATION_ERROR, "independent_tasks"): (
|
|
81
|
+
RecoveryActionType.RETRY_TASK, "task", False, False,
|
|
82
|
+
"transport error on an independent task — plain retry",
|
|
83
|
+
),
|
|
84
|
+
(FailureClass.COMMUNICATION_ERROR, "coordinated_training"): (
|
|
85
|
+
RecoveryActionType.RESTART_GROUP, "group", False, True,
|
|
86
|
+
"NCCL-class error — collective state is not repairable in place; whole-group restart",
|
|
87
|
+
),
|
|
88
|
+
(FailureClass.NETWORK_DEGRADATION, "independent_tasks"): (
|
|
89
|
+
RecoveryActionType.RETRY_TASK, "task", False, False,
|
|
90
|
+
"degraded link — move the task; the node's reliability score absorbs the signal",
|
|
91
|
+
),
|
|
92
|
+
(FailureClass.NETWORK_DEGRADATION, "coordinated_training"): (
|
|
93
|
+
RecoveryActionType.RESTART_GROUP, "group", False, True,
|
|
94
|
+
"sustained degradation starves the collective — restart, prefer a better pool",
|
|
95
|
+
),
|
|
96
|
+
# -- dependencies -------------------------------------------------------
|
|
97
|
+
(FailureClass.STORAGE_TIMEOUT, "independent_tasks"): (
|
|
98
|
+
RecoveryActionType.PAUSE_JOB, "job", False, False,
|
|
99
|
+
"storage outage — pause instead of burning compute against a dead dependency",
|
|
100
|
+
),
|
|
101
|
+
(FailureClass.STORAGE_TIMEOUT, "coordinated_training"): (
|
|
102
|
+
RecoveryActionType.PAUSE_JOB, "job", False, False,
|
|
103
|
+
"storage outage — pause instead of burning compute against a dead dependency",
|
|
104
|
+
),
|
|
105
|
+
(FailureClass.ARTIFACT_CORRUPTION, "independent_tasks"): (
|
|
106
|
+
RecoveryActionType.RETRY_TASK, "task", False, False,
|
|
107
|
+
"output failed validation — reject commit, retry on a different node, score down producer",
|
|
108
|
+
),
|
|
109
|
+
(FailureClass.ARTIFACT_CORRUPTION, "coordinated_training"): (
|
|
110
|
+
RecoveryActionType.RESTART_GROUP, "group", False, True,
|
|
111
|
+
"corrupt checkpoint/artifact — restart from the previous *valid* manifest",
|
|
112
|
+
),
|
|
113
|
+
(FailureClass.PREEMPTION, "independent_tasks"): (
|
|
114
|
+
RecoveryActionType.RETRY_TASK, "task", False, False,
|
|
115
|
+
"priced-in preemption — requeue; no penalty to anyone",
|
|
116
|
+
),
|
|
117
|
+
(FailureClass.PREEMPTION, "coordinated_training"): (
|
|
118
|
+
RecoveryActionType.REPLACE_NODE, "group", False, True,
|
|
119
|
+
"spot capacity reclaimed — acquire replacement, restart from checkpoint",
|
|
120
|
+
),
|
|
121
|
+
# -- systemic: stop acting ---------------------------------------------
|
|
122
|
+
(FailureClass.CORRELATED_INCIDENT, "independent_tasks"): (
|
|
123
|
+
RecoveryActionType.FREEZE_AUTOMATION, "pool", False, False,
|
|
124
|
+
"multiple simultaneous failures — no retry storms; preserve state and escalate",
|
|
125
|
+
),
|
|
126
|
+
(FailureClass.CORRELATED_INCIDENT, "coordinated_training"): (
|
|
127
|
+
RecoveryActionType.FREEZE_AUTOMATION, "pool", False, False,
|
|
128
|
+
"multiple simultaneous failures — no retry storms; preserve state and escalate",
|
|
129
|
+
),
|
|
130
|
+
(FailureClass.CONTROL_PLANE_FAILURE, "independent_tasks"): (
|
|
131
|
+
RecoveryActionType.PAUSE_JOB, "job", False, False,
|
|
132
|
+
"coordinator unreachable — workers finish current leases; no new grants",
|
|
133
|
+
),
|
|
134
|
+
(FailureClass.CONTROL_PLANE_FAILURE, "coordinated_training"): (
|
|
135
|
+
RecoveryActionType.PAUSE_JOB, "job", False, False,
|
|
136
|
+
"coordinator unreachable — preserve state; no automated decisions without a ledger",
|
|
137
|
+
),
|
|
138
|
+
(FailureClass.UNKNOWN, "independent_tasks"): (
|
|
139
|
+
RecoveryActionType.RETRY_TASK, "task", False, False,
|
|
140
|
+
"unclassified failure — one conservative retry; repeats escalate via attempt limits",
|
|
141
|
+
),
|
|
142
|
+
(FailureClass.UNKNOWN, "coordinated_training"): (
|
|
143
|
+
RecoveryActionType.PAUSE_JOB, "job", False, True,
|
|
144
|
+
"unclassified failure in coordinated training — pause for inspection over blind restart",
|
|
145
|
+
),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def decide(failure: FailureClass, mode: Mode, evidence: dict | None = None) -> RecoveryDecision:
|
|
150
|
+
"""Look up the typed action for a classified failure in a given mode.
|
|
151
|
+
|
|
152
|
+
Pure table lookup — deliberately no other inputs. Anything that should
|
|
153
|
+
influence the action (repeat counts, correlation) must appear upstream
|
|
154
|
+
as a different FailureClass, keeping every decision explainable by
|
|
155
|
+
(policy_version, failure_class, mode) alone.
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
action, scope, cordon, needs_ckpt, reason = _TABLE[(failure, mode)]
|
|
159
|
+
except KeyError as exc:
|
|
160
|
+
raise ValueError(f"no policy entry for {failure.value} in mode {mode!r}") from exc
|
|
161
|
+
return RecoveryDecision(
|
|
162
|
+
policy_version=POLICY_VERSION,
|
|
163
|
+
failure_class=failure,
|
|
164
|
+
action=action,
|
|
165
|
+
scope=scope, # type: ignore[arg-type]
|
|
166
|
+
cordon_node=cordon,
|
|
167
|
+
needs_checkpoint=needs_ckpt,
|
|
168
|
+
reason=reason,
|
|
169
|
+
evidence=evidence or {},
|
|
170
|
+
)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Translate a finished local process into recovery `FailureSignals`.
|
|
2
|
+
|
|
3
|
+
`from_local_launch(exit_code, log_tail)` is the first real caller of the
|
|
4
|
+
recovery package: it turns the only evidence a `LocalProcessLauncher` can
|
|
5
|
+
observe — the child's OS exit code and the tail of its captured stdout+stderr
|
|
6
|
+
— into the typed `FailureSignals` that `classify()` reads. It is a transparent
|
|
7
|
+
lookup table, not an inference engine: every rule is one `if` whose comment
|
|
8
|
+
names the reason the pattern implies its class, and the *first* matching rule
|
|
9
|
+
wins (order encodes priority — deterministic-bug evidence outranks the
|
|
10
|
+
transient default).
|
|
11
|
+
|
|
12
|
+
Rule table (checked top-to-bottom, first match wins). One rule, one reason:
|
|
13
|
+
|
|
14
|
+
1. exit 0 / never started → neutral signals (nothing broke; a guard
|
|
15
|
+
that keeps the function total).
|
|
16
|
+
2. a named deterministic
|
|
17
|
+
exception on a traceback
|
|
18
|
+
TERMINAL line → APPLICATION-ERROR (`exit_deterministic`):
|
|
19
|
+
import/parse/name errors recur
|
|
20
|
+
byte-for-byte, so fail fast, don't pay
|
|
21
|
+
for a re-run to the same certainty.
|
|
22
|
+
3. a bare "Traceback" that is
|
|
23
|
+
NOT the torchrun wrapper → APPLICATION-ERROR: an unhandled exception
|
|
24
|
+
is a code error by default.
|
|
25
|
+
4. everything else (signal death
|
|
26
|
+
/ OOM / bare SystemExit /
|
|
27
|
+
torchrun ChildFailedError) → WORKER-CRASH (the transient default):
|
|
28
|
+
bad luck, worth one fresh attempt.
|
|
29
|
+
|
|
30
|
+
Why rule 2 is anchored to a traceback TERMINAL line, not a bare substring:
|
|
31
|
+
CPython prints the failing exception type at the START of the traceback's
|
|
32
|
+
final line — `ModuleNotFoundError: ...`, or dotted with its defining module,
|
|
33
|
+
`pkg.mod.SomeError: ...` — whereas prose only ever *mentions* an exception
|
|
34
|
+
name mid-line. The canonical false positive is the startup warning
|
|
35
|
+
`ImportError: flash_attn not available, falling back to eager attention`,
|
|
36
|
+
which real loggers emit behind a timestamp/level prefix
|
|
37
|
+
(`2026-… WARNING ImportError: …`) so the name never begins the line. A plain
|
|
38
|
+
`substring in log` scan read that prose as a deterministic bug and failed the
|
|
39
|
+
job fast — and because fail-fast is terminal, that was the worst-direction
|
|
40
|
+
false positive: a transient crash that would have resumed instead never
|
|
41
|
+
retried. Line-anchoring keeps every genuine traceback terminal (rule 2 still
|
|
42
|
+
fires on the real thing) while ignoring the name wherever it appears in prose.
|
|
43
|
+
|
|
44
|
+
Interaction of rules 2, 3 and the torchrun carve-out (precedence, explicit):
|
|
45
|
+
torchrun / torch.distributed.elastic wraps *every* worker death — transient
|
|
46
|
+
crashes included — in a `ChildFailedError` and prints ITS OWN traceback; by
|
|
47
|
+
default the child's real traceback is not in this log (`error_file: <N/A>`).
|
|
48
|
+
That wrapper terminal line is `…errors.ChildFailedError:` — `ChildFailedError`
|
|
49
|
+
is not in the deterministic marker set, so rule 2 never fires on the wrapper.
|
|
50
|
+
The wrapper's bare "Traceback" line WOULD trip rule 3, so rule 3 is
|
|
51
|
+
disqualified whenever `ChildFailedError` is present, letting the death fall
|
|
52
|
+
through to the transient default (rule 4) — which is exactly what the
|
|
53
|
+
kill-and-resume e2e depends on. Rule 2 is intentionally NOT gated by the
|
|
54
|
+
wrapper: a genuine child terminal line (e.g. a real `ModuleNotFoundError:` if
|
|
55
|
+
elastic error-files are enabled) is a true deterministic bug and should fail
|
|
56
|
+
fast even under torchrun.
|
|
57
|
+
|
|
58
|
+
Deliberately narrow: it never fabricates node / accelerator / communication /
|
|
59
|
+
storage signals a single local process cannot actually evidence — those
|
|
60
|
+
classes belong to the distributed coordinator, not the local launcher.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
import re
|
|
66
|
+
|
|
67
|
+
from flashruntime.recovery.taxonomy import FailureSignals
|
|
68
|
+
|
|
69
|
+
# Exception-type names that mark a DETERMINISTIC bug: the same failure recurs
|
|
70
|
+
# byte-for-byte on retry, so retrying only re-reaches it.
|
|
71
|
+
_DETERMINISTIC_EXC_MARKERS = (
|
|
72
|
+
"SyntaxError", # the file will not parse — a retry parses the same file
|
|
73
|
+
"IndentationError", # a parse error too — same input, same failure
|
|
74
|
+
"ImportError", # a missing/broken dependency does not heal on retry
|
|
75
|
+
"ModuleNotFoundError", # ImportError's subclass; its name lacks the substring "ImportError"
|
|
76
|
+
"NameError", # an undefined name is a code bug, not bad luck
|
|
77
|
+
"AttributeError", # a code / version-contract bug, deterministic by nature
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# A marker only counts when it stands where CPython prints the failing type: at
|
|
81
|
+
# the START of the traceback's final line, either bare (`NameError`) or with a
|
|
82
|
+
# message (`NameError: ...`), optionally dotted with the defining module
|
|
83
|
+
# (`pkg.mod.NameError: ...`). Line-anchoring (see module docstring) is the fix
|
|
84
|
+
# for the incidental-substring false positive — exception names in prose don't
|
|
85
|
+
# start lines; traceback terminals always do. re.MULTILINE makes ^/$ match at
|
|
86
|
+
# every line boundary; the trailing `(?::|$)` boundary stops a marker from
|
|
87
|
+
# matching a longer name it merely prefixes (e.g. `NameErrorX:`).
|
|
88
|
+
_DETERMINISTIC_TERMINAL_RE = re.compile(
|
|
89
|
+
r"^(?:\w+\.)*(?:" + "|".join(_DETERMINISTIC_EXC_MARKERS) + r")(?::|$)",
|
|
90
|
+
re.MULTILINE,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# torchrun / elastic re-raises ChildFailedError (with its own traceback) for
|
|
94
|
+
# ANY worker death, transient ones included — see the module docstring. Its
|
|
95
|
+
# presence disqualifies the bare-traceback rule below.
|
|
96
|
+
_ELASTIC_WRAPPER_MARKER = "ChildFailedError"
|
|
97
|
+
_TRACEBACK_MARKER = "Traceback (most recent call last):"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def from_local_launch(exit_code: int | None, log_tail: str) -> FailureSignals:
|
|
101
|
+
"""Map a finished local process to the signals `classify()` reads.
|
|
102
|
+
|
|
103
|
+
`exit_code` is the child's OS return code (None if it never started);
|
|
104
|
+
`log_tail` is the tail of its captured stdout+stderr. Rules are checked
|
|
105
|
+
top-to-bottom, first match wins — see the module docstring for the full
|
|
106
|
+
table and the reasoning behind each class.
|
|
107
|
+
"""
|
|
108
|
+
log = log_tail or ""
|
|
109
|
+
|
|
110
|
+
# Rule 1 — a clean exit is not a failure; nothing to recover. Guard only:
|
|
111
|
+
# the retry loop never asks on SUCCEEDED, but this keeps the function total.
|
|
112
|
+
if exit_code == 0 or exit_code is None:
|
|
113
|
+
return FailureSignals(exit_code=exit_code)
|
|
114
|
+
|
|
115
|
+
# Rule 2 — a named deterministic bug (import / parse / name error) standing
|
|
116
|
+
# on a traceback TERMINAL line: the same error reappears on a byte-identical
|
|
117
|
+
# retry, so mark it deterministic and let the policy fail fast. Anchored to
|
|
118
|
+
# the line start (not a bare substring) so an exception name mentioned in a
|
|
119
|
+
# prose log line — e.g. `… WARNING ImportError: flash_attn not available` —
|
|
120
|
+
# is not mistaken for the failure that actually killed the process.
|
|
121
|
+
if _DETERMINISTIC_TERMINAL_RE.search(log):
|
|
122
|
+
return FailureSignals(exit_code=exit_code, exit_deterministic=True)
|
|
123
|
+
|
|
124
|
+
# Rule 3 — an unhandled exception that printed its own traceback:
|
|
125
|
+
# deterministic by default (unhandled exceptions are code errors). Excluded
|
|
126
|
+
# when it is the torchrun elastic wrapper, whose traceback is the launcher's
|
|
127
|
+
# stack and says nothing about whether the user's failure was deterministic.
|
|
128
|
+
if _TRACEBACK_MARKER in log and _ELASTIC_WRAPPER_MARKER not in log:
|
|
129
|
+
return FailureSignals(exit_code=exit_code, exit_deterministic=True)
|
|
130
|
+
|
|
131
|
+
# Rule 4 — everything else: a signal death / OOM (SIGKILL/SIGSEGV, exit 137,
|
|
132
|
+
# a negative POSIX returncode), a bare SystemExit with no traceback, or a
|
|
133
|
+
# torchrun ChildFailedError — all transient. Leave exit_deterministic False
|
|
134
|
+
# so classify() returns WORKER_CRASH and the policy grants a fresh attempt.
|
|
135
|
+
return FailureSignals(exit_code=exit_code)
|