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/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()