flexlock 0.8.2__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.
- flexlock/__init__.py +87 -0
- flexlock/api.py +1444 -0
- flexlock/backends/__init__.py +11 -0
- flexlock/backends/base.py +41 -0
- flexlock/backends/pbs.py +219 -0
- flexlock/backends/slurm.py +333 -0
- flexlock/cli.py +953 -0
- flexlock/config.py +121 -0
- flexlock/context.py +7 -0
- flexlock/data_hash.py +310 -0
- flexlock/debug.py +473 -0
- flexlock/diff.py +253 -0
- flexlock/diff_cli.py +155 -0
- flexlock/exceptions.py +50 -0
- flexlock/export.py +134 -0
- flexlock/fingerprint.py +129 -0
- flexlock/flexcli.py +163 -0
- flexlock/freeze.py +291 -0
- flexlock/git_utils.py +196 -0
- flexlock/index.py +300 -0
- flexlock/load_stage.py +60 -0
- flexlock/mlflow.py +192 -0
- flexlock/parallel.py +378 -0
- flexlock/query.py +739 -0
- flexlock/report.py +78 -0
- flexlock/report_template.html +246 -0
- flexlock/resolvers.py +248 -0
- flexlock/run_cli.py +44 -0
- flexlock/run_record.py +173 -0
- flexlock/runner.py +571 -0
- flexlock/save_dir.py +232 -0
- flexlock/skills/flexlock-new-stage/SKILL.md +57 -0
- flexlock/skills/flexlock-report/SKILL.md +50 -0
- flexlock/skills/flexlock-run-and-watch/SKILL.md +60 -0
- flexlock/skills/flexlock-survey/SKILL.md +61 -0
- flexlock/snapshot.py +245 -0
- flexlock/status_cli.py +299 -0
- flexlock/taskdb.py +685 -0
- flexlock/utils.py +710 -0
- flexlock/worker.py +228 -0
- flexlock/worker_cli.py +143 -0
- flexlock-0.8.2.dist-info/METADATA +357 -0
- flexlock-0.8.2.dist-info/RECORD +45 -0
- flexlock-0.8.2.dist-info/WHEEL +4 -0
- flexlock-0.8.2.dist-info/entry_points.txt +7 -0
flexlock/worker.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Worker process for executing FlexLock tasks.
|
|
2
|
+
|
|
3
|
+
The worker is deliberately single-threaded while the user function runs: no
|
|
4
|
+
background heartbeat thread or process. A live background thread would turn
|
|
5
|
+
any fork the user's code performs (PyTorch DataLoader workers, DDP, ...) into
|
|
6
|
+
a multi-threaded fork and corrupt the CUDA runtime in the child
|
|
7
|
+
(``cudaErrorSystemNotReady`` / "Unexpected error from cudaGetDeviceCount()").
|
|
8
|
+
Liveness is instead reconciled by the controller against the OS/scheduler's
|
|
9
|
+
authoritative view of worker state (see ``ParallelExecutor``).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import random
|
|
15
|
+
import time
|
|
16
|
+
import traceback
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from loguru import logger
|
|
20
|
+
|
|
21
|
+
from .taskdb import claim_next_tasks, finish_tasks, pending_count
|
|
22
|
+
from flexlock.utils import merge_task_into_cfg, instantiate, extract_tracking_info
|
|
23
|
+
from flexlock.resolvers import resolve_deferred
|
|
24
|
+
from flexlock.snapshot import snapshot
|
|
25
|
+
from flexlock.run_record import RunRecord
|
|
26
|
+
from flexlock.fingerprint import fingerprint as compute_fingerprint
|
|
27
|
+
from flexlock import index
|
|
28
|
+
from flexlock import config as _config
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _current_job_id() -> str | None:
|
|
32
|
+
"""Best-effort scheduler job id from the environment (SLURM/PBS)."""
|
|
33
|
+
return (
|
|
34
|
+
os.getenv("SLURM_JOB_ID")
|
|
35
|
+
or os.getenv("PBS_JOBID")
|
|
36
|
+
or None
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _preflight_cuda() -> "tuple[bool, str]":
|
|
41
|
+
"""Check that a CUDA context can actually be created on this node.
|
|
42
|
+
|
|
43
|
+
Returns ``(ok, detail)``. Enabling this (``FLEXLOCK_PREFLIGHT_CUDA=1``)
|
|
44
|
+
asserts "this job needs a working GPU", so anything short of a successful
|
|
45
|
+
allocation is a failure:
|
|
46
|
+
|
|
47
|
+
- torch not importable -> skipped (``ok=True``); it's a misconfiguration,
|
|
48
|
+
not a node fault, and the user function will report it plainly.
|
|
49
|
+
- ``cuda.is_available()`` False, or a real allocation raises -> ``ok=False``.
|
|
50
|
+
A bare ``torch.zeros(1, device='cuda')`` is exactly what fails with
|
|
51
|
+
``cudaErrorSystemNotReady`` (802) on a node whose GPU stack is unhealthy
|
|
52
|
+
(Fabric Manager down, driver/kernel-module mismatch).
|
|
53
|
+
- allocation succeeds -> ``ok=True``.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
import torch
|
|
57
|
+
except Exception as e: # torch absent / broken install
|
|
58
|
+
return True, f"skipped (torch not importable: {e})"
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
if not torch.cuda.is_available():
|
|
62
|
+
return False, "torch.cuda.is_available() is False (no usable GPU)"
|
|
63
|
+
x = torch.zeros(1, device="cuda")
|
|
64
|
+
torch.cuda.synchronize()
|
|
65
|
+
del x
|
|
66
|
+
return True, f"ok ({torch.cuda.get_device_name(0)})"
|
|
67
|
+
except Exception as e:
|
|
68
|
+
return False, f"{type(e).__name__}: {e}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def worker_loop(func, cfg, task_to: str, db_path, tags=None):
|
|
72
|
+
"""Claim and execute tasks from the task database until none remain.
|
|
73
|
+
|
|
74
|
+
``tags`` restricts which rows this worker claims (and the idle-check
|
|
75
|
+
pending count). ``None`` (default) claims any row, matching legacy
|
|
76
|
+
untagged DBs and the ``flexlock-worker`` CLI without ``--tags``.
|
|
77
|
+
"""
|
|
78
|
+
if func is None:
|
|
79
|
+
func = instantiate
|
|
80
|
+
node = os.getenv("HOSTNAME") or "local"
|
|
81
|
+
job_id = _current_job_id()
|
|
82
|
+
|
|
83
|
+
db_path = Path(db_path)
|
|
84
|
+
db_dir = db_path.parent
|
|
85
|
+
master_lock = db_dir / "run.lock"
|
|
86
|
+
|
|
87
|
+
# Optional GPU pre-flight: fail fast on an unhealthy node before claiming
|
|
88
|
+
# any task, so a bad GPU stack surfaces as one clear log line rather than
|
|
89
|
+
# an opaque CUDA traceback deep inside the user's training. Tasks are left
|
|
90
|
+
# pending for a healthy worker to pick up.
|
|
91
|
+
if _config.PREFLIGHT_CUDA:
|
|
92
|
+
ok, detail = _preflight_cuda()
|
|
93
|
+
if not ok:
|
|
94
|
+
logger.error(
|
|
95
|
+
f"[preflight] CUDA unusable on node {node}: {detail}. "
|
|
96
|
+
f"Not claiming any task — they stay pending for another worker. "
|
|
97
|
+
f"This is almost always a node/driver problem (NVIDIA Fabric "
|
|
98
|
+
f"Manager down or driver/kernel-module mismatch), not your code. "
|
|
99
|
+
f"Resubmit excluding this node (e.g. sbatch --exclude={node})."
|
|
100
|
+
)
|
|
101
|
+
return
|
|
102
|
+
logger.info(f"[preflight] CUDA {detail} on node {node}")
|
|
103
|
+
|
|
104
|
+
# Stagger startup so multiple workers don't all claim the same task.
|
|
105
|
+
time.sleep(random.uniform(0, 5))
|
|
106
|
+
|
|
107
|
+
# Claiming several tasks per transaction (FLEXLOCK_CLAIM_BATCH) divides
|
|
108
|
+
# the write-lock traffic on the shared DB by the batch size; per-task
|
|
109
|
+
# outcomes are buffered and flushed in one transaction per batch. A worker
|
|
110
|
+
# that dies mid-batch strands the unstarted claims as 'running' — the
|
|
111
|
+
# controller's orphan reconcile (or --reclaim) recovers them, same as a
|
|
112
|
+
# death mid-task today.
|
|
113
|
+
claim_batch = max(1, _config.CLAIM_BATCH)
|
|
114
|
+
|
|
115
|
+
while True:
|
|
116
|
+
batch = claim_next_tasks(
|
|
117
|
+
db_path, node, job_id, tags=tags, n=claim_batch
|
|
118
|
+
)
|
|
119
|
+
if not batch:
|
|
120
|
+
if pending_count(db_path, tags=tags) == 0:
|
|
121
|
+
logger.info("All tasks finished.")
|
|
122
|
+
break
|
|
123
|
+
logger.debug("No task available – sleeping 5s")
|
|
124
|
+
time.sleep(5)
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
finished: list[dict] = []
|
|
128
|
+
try:
|
|
129
|
+
for task in batch:
|
|
130
|
+
_run_one_task(
|
|
131
|
+
func, cfg, task_to, db_path, db_dir, master_lock,
|
|
132
|
+
node, task, finished,
|
|
133
|
+
)
|
|
134
|
+
finally:
|
|
135
|
+
finish_tasks(db_path, finished)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _run_one_task(
|
|
139
|
+
func, cfg, task_to, db_path, db_dir, master_lock, node, task, finished
|
|
140
|
+
):
|
|
141
|
+
"""Execute one claimed task; append its outcome record to ``finished``.
|
|
142
|
+
|
|
143
|
+
The record is written to the DB by the caller's per-batch
|
|
144
|
+
``finish_tasks`` flush (also on interrupt, via its ``finally``).
|
|
145
|
+
"""
|
|
146
|
+
logger.info(f"Worker {node} running task {task}")
|
|
147
|
+
|
|
148
|
+
from flexlock.taskdb import _hash_task
|
|
149
|
+
task_id = _hash_task(task)
|
|
150
|
+
|
|
151
|
+
task_save_dir = None
|
|
152
|
+
try:
|
|
153
|
+
task_cfg = merge_task_into_cfg(cfg, task, task_to)
|
|
154
|
+
|
|
155
|
+
# Single deferred-resolution point: run_lock/latest fire exactly
|
|
156
|
+
# once, here at stage start on the worker, now that the per-item
|
|
157
|
+
# override has been merged in. Everything else was already frozen
|
|
158
|
+
# to concrete values at submit time.
|
|
159
|
+
task_cfg = resolve_deferred(task_cfg)
|
|
160
|
+
|
|
161
|
+
repos, data, prevs = extract_tracking_info(task_cfg)
|
|
162
|
+
|
|
163
|
+
task_save_dir = Path(task_cfg.get("save_dir", db_dir / f"task_{task_id}"))
|
|
164
|
+
task_save_dir.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
|
|
166
|
+
# Compute the fingerprint with the task's full git identity (repos
|
|
167
|
+
# from the merged config) so a task cache-hits when the same config
|
|
168
|
+
# is later run serially. The run.lock snapshot itself still uses the
|
|
169
|
+
# parent_lock delta optimisation (repos=None).
|
|
170
|
+
try:
|
|
171
|
+
task_fp = compute_fingerprint(task_cfg, repos=repos, data=data)
|
|
172
|
+
except Exception as exc:
|
|
173
|
+
logger.warning(f"Could not compute task fingerprint: {exc}")
|
|
174
|
+
task_fp = None
|
|
175
|
+
|
|
176
|
+
snapshot_data = snapshot(
|
|
177
|
+
task_cfg,
|
|
178
|
+
data=data,
|
|
179
|
+
prevs=prevs,
|
|
180
|
+
repos=None,
|
|
181
|
+
parent_lock=str(master_lock) if master_lock.exists() else None,
|
|
182
|
+
return_snapshot=True,
|
|
183
|
+
fingerprint=task_fp,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if snapshot_data:
|
|
187
|
+
from flexlock.taskdb import update_task_snapshot
|
|
188
|
+
update_task_snapshot(db_path, task_id, snapshot_data)
|
|
189
|
+
|
|
190
|
+
marker_file = task_save_dir / ".flexlock_marker"
|
|
191
|
+
db_abs = db_path.resolve()
|
|
192
|
+
try:
|
|
193
|
+
db_str = str(db_abs.relative_to(task_save_dir.parent.resolve()))
|
|
194
|
+
except ValueError:
|
|
195
|
+
db_str = str(db_abs)
|
|
196
|
+
marker_file.write_text(json.dumps({"db": db_str, "task_id": task_id}, indent=2))
|
|
197
|
+
|
|
198
|
+
result = func(task_cfg)
|
|
199
|
+
logger.info(f"Task successful: {task_cfg}")
|
|
200
|
+
|
|
201
|
+
record = RunRecord(task_save_dir)
|
|
202
|
+
try:
|
|
203
|
+
record.write_results(result)
|
|
204
|
+
except Exception as e:
|
|
205
|
+
logger.warning(f"Could not write results.json at {task_save_dir}: {e}")
|
|
206
|
+
|
|
207
|
+
record.mark_complete(result=result)
|
|
208
|
+
# Record this sweep task in the project-wide index so it's a
|
|
209
|
+
# first-class cache entry (issue 1).
|
|
210
|
+
if task_fp:
|
|
211
|
+
index.record_task(task_save_dir, db_path, task_id, task_fp)
|
|
212
|
+
finished.append(dict(task=task, result=result))
|
|
213
|
+
|
|
214
|
+
except KeyboardInterrupt:
|
|
215
|
+
# SIGINT — mark the in-flight task interrupted so it's distinct
|
|
216
|
+
# from a genuine failure, then propagate so the worker exits.
|
|
217
|
+
logger.warning(f"Worker interrupted while running task {task_id}")
|
|
218
|
+
finished.append(dict(task=task, status="interrupted"))
|
|
219
|
+
raise
|
|
220
|
+
|
|
221
|
+
except Exception as e:
|
|
222
|
+
tb = traceback.format_exc()
|
|
223
|
+
logger.exception(f"Task failed: {e}")
|
|
224
|
+
# Mirror the failure into the task dir as run.error so triage needs
|
|
225
|
+
# no sqlite; taskdb keeps the traceback too. write_error never raises.
|
|
226
|
+
if task_save_dir is not None:
|
|
227
|
+
RunRecord(task_save_dir).write_error(e, task_id=task_id, node=node)
|
|
228
|
+
finished.append(dict(task=task, error=tb))
|
flexlock/worker_cli.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""CLI entry point for attaching extra workers to an existing task DB."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import multiprocessing
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from loguru import logger
|
|
9
|
+
from omegaconf import OmegaConf
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main():
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
description="Attach one or more workers to an existing FlexLock task DB.",
|
|
15
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
16
|
+
epilog="""
|
|
17
|
+
Examples:
|
|
18
|
+
# Attach 4 local workers to an existing DB
|
|
19
|
+
flexlock-worker --task-db outputs/run/run.lock.tasks.db --n-jobs 4
|
|
20
|
+
|
|
21
|
+
# Only process tasks tagged "extract"
|
|
22
|
+
flexlock-worker --task-db outputs/run/run.lock.tasks.db --tags extract --n-jobs 4
|
|
23
|
+
|
|
24
|
+
# Reclaim stranded tasks for tag "collocate" then re-run them
|
|
25
|
+
flexlock-worker --task-db outputs/run/run.lock.tasks.db --tags collocate --reclaim
|
|
26
|
+
""",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--task-db",
|
|
30
|
+
required=True,
|
|
31
|
+
metavar="FILE",
|
|
32
|
+
help="Path to the .tasks.db file created by a previous flexlock-run sweep.",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--n-jobs",
|
|
36
|
+
type=int,
|
|
37
|
+
default=1,
|
|
38
|
+
metavar="N",
|
|
39
|
+
help="Number of parallel worker processes to spawn locally (default: 1). "
|
|
40
|
+
"Ignored when --slurm-config or --pbs-config is used.",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--tags",
|
|
44
|
+
metavar="TAG[,TAG...]",
|
|
45
|
+
help="Comma-separated list of tags to process. Workers will only claim "
|
|
46
|
+
"tasks whose tag matches. Omit to process all tasks (including "
|
|
47
|
+
"legacy untagged rows).",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--reclaim",
|
|
51
|
+
action="store_true",
|
|
52
|
+
help="Before starting, reset tasks stranded in 'running' (by a worker "
|
|
53
|
+
"that died without cleanup) back to 'pending' so they re-run. "
|
|
54
|
+
"Scoped to --tags when provided.",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
backend_group = parser.add_mutually_exclusive_group()
|
|
58
|
+
backend_group.add_argument(
|
|
59
|
+
"--slurm-config",
|
|
60
|
+
metavar="FILE",
|
|
61
|
+
help="Submit workers as a Slurm job using this YAML config.",
|
|
62
|
+
)
|
|
63
|
+
backend_group.add_argument(
|
|
64
|
+
"--pbs-config",
|
|
65
|
+
metavar="FILE",
|
|
66
|
+
help="Submit workers as a PBS job using this YAML config.",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
args = parser.parse_args()
|
|
70
|
+
|
|
71
|
+
db_path = Path(args.task_db)
|
|
72
|
+
if not db_path.exists():
|
|
73
|
+
logger.error(f"Task DB not found: {db_path}")
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
|
|
76
|
+
# Parse --tags into a list (None when omitted).
|
|
77
|
+
tags = [t.strip() for t in args.tags.split(",")] if args.tags else None
|
|
78
|
+
|
|
79
|
+
from flexlock.taskdb import pending_count, reclaim_running
|
|
80
|
+
from flexlock.worker import worker_loop
|
|
81
|
+
|
|
82
|
+
if args.reclaim:
|
|
83
|
+
reclaimed = reclaim_running(db_path, tags=tags)
|
|
84
|
+
logger.info(f"Reclaimed {reclaimed} stranded 'running' task(s) → pending"
|
|
85
|
+
+ (f" (tags={tags})" if tags else ""))
|
|
86
|
+
|
|
87
|
+
n_pending = pending_count(db_path, tags=tags)
|
|
88
|
+
if n_pending == 0:
|
|
89
|
+
logger.info("No pending tasks in DB — nothing to do."
|
|
90
|
+
+ (f" (tags={tags})" if tags else ""))
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
# Tasks stored in the DB are complete configs; pass an empty base cfg and
|
|
94
|
+
# no task_to so worker_loop uses each task dict as-is.
|
|
95
|
+
empty_cfg = OmegaConf.create({})
|
|
96
|
+
|
|
97
|
+
if args.slurm_config or args.pbs_config:
|
|
98
|
+
logs_dir = db_path.parent / ("slurm_logs" if args.slurm_config else "pbs_logs")
|
|
99
|
+
if args.slurm_config:
|
|
100
|
+
from flexlock.backends.slurm import SlurmBackend
|
|
101
|
+
params = OmegaConf.to_container(OmegaConf.load(args.slurm_config), resolve=True)
|
|
102
|
+
backend = SlurmBackend(folder=logs_dir, **params)
|
|
103
|
+
else:
|
|
104
|
+
from flexlock.backends.pbs import PBSBackend
|
|
105
|
+
params = OmegaConf.to_container(OmegaConf.load(args.pbs_config), resolve=True)
|
|
106
|
+
backend = PBSBackend(folder=logs_dir, **params)
|
|
107
|
+
|
|
108
|
+
logger.info(
|
|
109
|
+
f"Submitting {backend.__class__.__name__} worker job for {db_path} "
|
|
110
|
+
f"({n_pending} pending tasks"
|
|
111
|
+
+ (f", tags={tags}" if tags else "")
|
|
112
|
+
+ ")"
|
|
113
|
+
)
|
|
114
|
+
job = backend.submit(
|
|
115
|
+
worker_loop, None, empty_cfg, None, db_path, tags
|
|
116
|
+
)
|
|
117
|
+
logger.info(f"Submitted job {job.job_id}")
|
|
118
|
+
else:
|
|
119
|
+
logger.info(
|
|
120
|
+
f"Attaching {args.n_jobs} local worker(s) to {db_path} "
|
|
121
|
+
f"({n_pending} pending tasks"
|
|
122
|
+
+ (f", tags={tags}" if tags else "")
|
|
123
|
+
+ ")"
|
|
124
|
+
)
|
|
125
|
+
if args.n_jobs == 1:
|
|
126
|
+
worker_loop(None, empty_cfg, None, db_path, tags)
|
|
127
|
+
else:
|
|
128
|
+
ctx = multiprocessing.get_context("spawn")
|
|
129
|
+
procs = [
|
|
130
|
+
ctx.Process(
|
|
131
|
+
target=worker_loop,
|
|
132
|
+
args=(None, empty_cfg, None, db_path, tags),
|
|
133
|
+
)
|
|
134
|
+
for _ in range(args.n_jobs)
|
|
135
|
+
]
|
|
136
|
+
for p in procs:
|
|
137
|
+
p.start()
|
|
138
|
+
for p in procs:
|
|
139
|
+
p.join()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
main()
|