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
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Base classes for FlexLock backend integrations."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any, List
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Job:
|
|
8
|
+
"""Abstract base class representing a submitted job."""
|
|
9
|
+
|
|
10
|
+
@property
|
|
11
|
+
def job_id(self) -> str:
|
|
12
|
+
"""The unique identifier for the job."""
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JobEnvironment:
|
|
17
|
+
"""Abstract base class providing environment information for a running job."""
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def global_rank(self) -> int:
|
|
21
|
+
"""The global rank of the current process within the job (0-indexed)."""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def world_size(self) -> int:
|
|
26
|
+
"""The total number of processes in the job."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Backend(ABC):
|
|
31
|
+
"""Abstract base class for FlexLock job submission backends."""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def submit(self, fn, *args, **kwargs) -> Job:
|
|
35
|
+
"""Submits a single function for execution."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def environment(self) -> JobEnvironment:
|
|
40
|
+
"""Returns a JobEnvironment object providing environment-specific variables."""
|
|
41
|
+
...
|
flexlock/backends/pbs.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""PBS backend for FlexLock parallel execution."""
|
|
2
|
+
|
|
3
|
+
import cloudpickle, subprocess, os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import secrets
|
|
6
|
+
import time
|
|
7
|
+
from .base import Backend, Job, JobEnvironment
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PBSJob(Job):
|
|
12
|
+
"""Represents a PBS job."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, job_id, backend=None):
|
|
15
|
+
self._id = job_id
|
|
16
|
+
self._backend = backend
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def job_id(self):
|
|
20
|
+
return self._id
|
|
21
|
+
|
|
22
|
+
def status(self):
|
|
23
|
+
"""Get current job status."""
|
|
24
|
+
if self._backend:
|
|
25
|
+
return self._backend.check_status(self._id)
|
|
26
|
+
return "unknown"
|
|
27
|
+
|
|
28
|
+
def wait(self, timeout=None, poll_interval=5):
|
|
29
|
+
"""Wait for job to complete."""
|
|
30
|
+
if self._backend:
|
|
31
|
+
return self._backend.wait_for_job(self._id, timeout, poll_interval)
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
def cancel(self):
|
|
35
|
+
"""Cancel the job."""
|
|
36
|
+
if self._backend:
|
|
37
|
+
return self._backend.cancel_job(self._id)
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PBSBackend(Backend):
|
|
42
|
+
"""Implements the FlexLock backend for PBS (Portable Batch System) job submission."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
folder: Path,
|
|
47
|
+
startup_lines: list[str],
|
|
48
|
+
configure_logging: bool = True,
|
|
49
|
+
configure_name: bool = True,
|
|
50
|
+
python_exe: str = "python",
|
|
51
|
+
):
|
|
52
|
+
self.folder = folder
|
|
53
|
+
self.folder.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
self.startup_lines = startup_lines
|
|
55
|
+
self.configure_logging = configure_logging
|
|
56
|
+
self.configure_name = configure_name
|
|
57
|
+
self.python_exe = python_exe
|
|
58
|
+
|
|
59
|
+
def _make_script(self, pickled_path: Path) -> str:
|
|
60
|
+
"""Generates the PBS submission script content."""
|
|
61
|
+
lines = ["#!/bin/bash"]
|
|
62
|
+
|
|
63
|
+
if self.configure_name:
|
|
64
|
+
lines.extend(
|
|
65
|
+
[
|
|
66
|
+
f"#PBS -N {self.folder.parent.stem}",
|
|
67
|
+
]
|
|
68
|
+
)
|
|
69
|
+
if self.configure_logging:
|
|
70
|
+
lines.extend(
|
|
71
|
+
[
|
|
72
|
+
f"#PBS -o {self.folder.absolute() / 'pbs.out'}",
|
|
73
|
+
f"#PBS -e {self.folder.absolute() / 'pbs.err'}",
|
|
74
|
+
]
|
|
75
|
+
)
|
|
76
|
+
lines.extend(self.startup_lines)
|
|
77
|
+
python_script = [
|
|
78
|
+
"import cloudpickle, sys, os",
|
|
79
|
+
f"with open('{pickled_path}', 'rb') as f:",
|
|
80
|
+
" fn, a, kw = cloudpickle.load(f)",
|
|
81
|
+
"fn(*a, **kw)",
|
|
82
|
+
]
|
|
83
|
+
python_code = "\n".join(python_script)
|
|
84
|
+
|
|
85
|
+
lines.extend(
|
|
86
|
+
[
|
|
87
|
+
f"{self.python_exe} - <<'PY'\n{python_code}\nPY",
|
|
88
|
+
]
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return "\n".join(lines)
|
|
92
|
+
|
|
93
|
+
def submit(self, fn, *args, **kwargs):
|
|
94
|
+
"""Submits a single function for execution as a PBS job."""
|
|
95
|
+
data = (fn, args, kwargs)
|
|
96
|
+
pkl_path = self.folder / f"task_{secrets.token_hex(4)}.pkl"
|
|
97
|
+
with open(pkl_path, "wb") as f:
|
|
98
|
+
cloudpickle.dump(data, f)
|
|
99
|
+
|
|
100
|
+
script_path = self.folder / f"job_{secrets.token_hex(4)}.pbs"
|
|
101
|
+
script_path.write_text(self._make_script(pkl_path))
|
|
102
|
+
|
|
103
|
+
out = subprocess.check_output(["qsub", str(script_path)], text=True).strip()
|
|
104
|
+
job_id = out
|
|
105
|
+
return PBSJob(job_id, backend=self)
|
|
106
|
+
|
|
107
|
+
def check_status(self, job_id: str) -> str:
|
|
108
|
+
"""
|
|
109
|
+
Check the status of a PBS job.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Status string: 'Q' (queued), 'R' (running), 'C' (completed), 'E' (exiting), 'H' (held), or 'unknown'
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
# Use qstat -x to get info about finished jobs too
|
|
116
|
+
out = subprocess.check_output(
|
|
117
|
+
["qstat", "-x", job_id], text=True, stderr=subprocess.DEVNULL
|
|
118
|
+
)
|
|
119
|
+
lines = out.strip().split("\n")
|
|
120
|
+
if len(lines) > 2: # Header + job line
|
|
121
|
+
# Parse the status from qstat output
|
|
122
|
+
job_line = lines[2] # Skip two header lines
|
|
123
|
+
parts = job_line.split()
|
|
124
|
+
if len(parts) >= 10:
|
|
125
|
+
return parts[9] # Job state is usually the 10th column
|
|
126
|
+
except subprocess.CalledProcessError:
|
|
127
|
+
# Job not found, likely completed and cleaned up
|
|
128
|
+
return "C"
|
|
129
|
+
except Exception as e:
|
|
130
|
+
logger.warning(f"Failed to check PBS job status for {job_id}: {e}")
|
|
131
|
+
return "unknown"
|
|
132
|
+
|
|
133
|
+
# States that mean the job has definitively ended: C(completed) F(finished).
|
|
134
|
+
# Everything else — active (Q/R/H/E/...) or "unknown" — is NOT terminal.
|
|
135
|
+
TERMINAL_STATES = frozenset({"C", "F"})
|
|
136
|
+
|
|
137
|
+
def is_terminal(self, job_id: str) -> bool:
|
|
138
|
+
"""Return True only when PBS confirms the job has ended.
|
|
139
|
+
|
|
140
|
+
Conservative on purpose (see ``SlurmBackend.is_terminal``): ambiguous
|
|
141
|
+
reads return ``False`` so a healthy job is never reconciled away. The
|
|
142
|
+
controller additionally debounces across several polls, which guards
|
|
143
|
+
against ``qstat`` transiently failing (``check_status`` maps a
|
|
144
|
+
not-found job to ``"C"``).
|
|
145
|
+
"""
|
|
146
|
+
return self.check_status(job_id).strip().upper() in self.TERMINAL_STATES
|
|
147
|
+
|
|
148
|
+
def wait_for_job(self, job_id: str, timeout=None, poll_interval=5) -> bool:
|
|
149
|
+
"""
|
|
150
|
+
Wait for a PBS job to complete.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
job_id: PBS job identifier
|
|
154
|
+
timeout: Maximum time to wait in seconds (None for no timeout)
|
|
155
|
+
poll_interval: Time between status checks in seconds
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
True if job completed successfully, False otherwise
|
|
159
|
+
"""
|
|
160
|
+
start_time = time.time()
|
|
161
|
+
logger.info(f"Waiting for PBS job {job_id} to complete...")
|
|
162
|
+
|
|
163
|
+
while True:
|
|
164
|
+
status = self.check_status(job_id)
|
|
165
|
+
|
|
166
|
+
# Completed states
|
|
167
|
+
if status in ["C", "completed"]:
|
|
168
|
+
logger.info(f"PBS job {job_id} completed")
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
# Failed states
|
|
172
|
+
if status in ["E", "F", "failed"]:
|
|
173
|
+
logger.error(f"PBS job {job_id} failed with status: {status}")
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
# Check timeout
|
|
177
|
+
if timeout and (time.time() - start_time) > timeout:
|
|
178
|
+
logger.error(f"PBS job {job_id} timed out after {timeout}s")
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
# Still running or queued
|
|
182
|
+
if status in ["Q", "R", "H"]:
|
|
183
|
+
logger.debug(f"PBS job {job_id} status: {status}")
|
|
184
|
+
else:
|
|
185
|
+
logger.debug(f"PBS job {job_id} unknown status: {status}")
|
|
186
|
+
|
|
187
|
+
time.sleep(poll_interval)
|
|
188
|
+
|
|
189
|
+
def cancel_job(self, job_id: str) -> bool:
|
|
190
|
+
"""
|
|
191
|
+
Cancel a PBS job.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
job_id: PBS job identifier
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
True if cancellation succeeded, False otherwise
|
|
198
|
+
"""
|
|
199
|
+
try:
|
|
200
|
+
subprocess.check_call(["qdel", job_id], stderr=subprocess.DEVNULL)
|
|
201
|
+
logger.info(f"Cancelled PBS job {job_id}")
|
|
202
|
+
return True
|
|
203
|
+
except subprocess.CalledProcessError as e:
|
|
204
|
+
logger.error(f"Failed to cancel PBS job {job_id}: {e}")
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
def environment(self):
|
|
208
|
+
"""Returns a JobEnvironment object providing PBS-specific environment variables."""
|
|
209
|
+
|
|
210
|
+
class Env(JobEnvironment):
|
|
211
|
+
@property
|
|
212
|
+
def global_rank(self):
|
|
213
|
+
return int(os.getenv("OMPI_COMM_WORLD_RANK", 0))
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def world_size(self):
|
|
217
|
+
return int(os.getenv("OMPI_COMM_WORLD_SIZE", 1))
|
|
218
|
+
|
|
219
|
+
return Env()
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Slurm backend for FlexLock parallel execution."""
|
|
2
|
+
|
|
3
|
+
import cloudpickle, subprocess, os
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import secrets # Better random for filenames
|
|
7
|
+
import time
|
|
8
|
+
from .base import Backend, Job, JobEnvironment
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Heuristics for recognising environment-activation lines in startup_lines.
|
|
13
|
+
# These commands prepare the compute-node shell (PATH, libs, interpreters)
|
|
14
|
+
# before the pickled task runs. Missing them is the wiki's most common
|
|
15
|
+
# silent-failure mode ("import fails on node, looks fine locally").
|
|
16
|
+
_ENV_ACTIVATION_PATTERNS = (
|
|
17
|
+
re.compile(r"\beval\s"), # eval "$(...)" — pixi shell-hook, conda hook
|
|
18
|
+
re.compile(r"\bsource\s"), # source venv/bin/activate
|
|
19
|
+
re.compile(r"\bconda\s+activate\b"),
|
|
20
|
+
re.compile(r"\bmamba\s+activate\b"),
|
|
21
|
+
re.compile(r"\bmodule\s+(load|add)\b"), # HPC module systems
|
|
22
|
+
re.compile(r"\bpixi\s+(run|shell)\b"),
|
|
23
|
+
re.compile(r"\bspack\s+load\b"),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def validate_slurm_script(
|
|
28
|
+
script: str, *, expects_gpu: bool = False
|
|
29
|
+
) -> list[str]:
|
|
30
|
+
"""Return a list of human-readable warning strings for a Slurm script.
|
|
31
|
+
|
|
32
|
+
Empty list means no concerns. Each warning is independent — callers
|
|
33
|
+
decide whether to log or print. Validation is intentionally lenient
|
|
34
|
+
(warn, never fail) because power-user setups may intentionally omit
|
|
35
|
+
pieces this function expects.
|
|
36
|
+
"""
|
|
37
|
+
warnings: list[str] = []
|
|
38
|
+
|
|
39
|
+
sbatch_lines = [
|
|
40
|
+
ln.strip() for ln in script.splitlines() if ln.strip().startswith("#SBATCH")
|
|
41
|
+
]
|
|
42
|
+
body_lines = [
|
|
43
|
+
ln for ln in script.splitlines()
|
|
44
|
+
if ln.strip() and not ln.strip().startswith(("#!", "#SBATCH"))
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
# 1. --partition is the most common forgotten directive — jobs land on
|
|
48
|
+
# the default queue, often a CPU one when a GPU was wanted.
|
|
49
|
+
if not any("--partition" in ln or "-p " in ln for ln in sbatch_lines):
|
|
50
|
+
warnings.append(
|
|
51
|
+
"No --partition directive found. The job will land on the "
|
|
52
|
+
"cluster's default queue, which may not be the one you want."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# 2. cd to the submission dir keeps relative paths in user code working
|
|
56
|
+
# from the compute node.
|
|
57
|
+
if not any(re.search(r"\bcd\b", ln) for ln in body_lines):
|
|
58
|
+
warnings.append(
|
|
59
|
+
"No 'cd' command in startup_lines. Relative paths in your "
|
|
60
|
+
"code will resolve against the compute node's HOME, not the "
|
|
61
|
+
"submission directory. Add 'cd $SLURM_SUBMIT_DIR' to "
|
|
62
|
+
"startup_lines."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# 3. Some form of env activation is almost always needed.
|
|
66
|
+
if not any(p.search(ln) for ln in body_lines for p in _ENV_ACTIVATION_PATTERNS):
|
|
67
|
+
warnings.append(
|
|
68
|
+
"No environment-activation command detected in startup_lines "
|
|
69
|
+
"(eval, source, conda/mamba activate, module load, pixi run/shell). "
|
|
70
|
+
"Python imports may fail on the compute node."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# 4. If the caller flagged this as a GPU config but the script doesn't
|
|
74
|
+
# request a GPU, surface it.
|
|
75
|
+
if expects_gpu and not any(
|
|
76
|
+
"--gres" in ln or "--gpus" in ln for ln in sbatch_lines
|
|
77
|
+
):
|
|
78
|
+
warnings.append(
|
|
79
|
+
"GPU config requested but no --gres or --gpus directive in "
|
|
80
|
+
"startup_lines. The job will run on CPU silently."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return warnings
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class SlurmJob(Job):
|
|
87
|
+
"""Represents a Slurm job."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, job_id, backend=None):
|
|
90
|
+
self._id = job_id
|
|
91
|
+
self._backend = backend
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def job_id(self):
|
|
95
|
+
return self._id
|
|
96
|
+
|
|
97
|
+
def status(self):
|
|
98
|
+
"""Get current job status."""
|
|
99
|
+
if self._backend:
|
|
100
|
+
return self._backend.check_status(self._id)
|
|
101
|
+
return "unknown"
|
|
102
|
+
|
|
103
|
+
def wait(self, timeout=None, poll_interval=5):
|
|
104
|
+
"""Wait for job to complete."""
|
|
105
|
+
if self._backend:
|
|
106
|
+
return self._backend.wait_for_job(self._id, timeout, poll_interval)
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
def cancel(self):
|
|
110
|
+
"""Cancel the job."""
|
|
111
|
+
if self._backend:
|
|
112
|
+
return self._backend.cancel_job(self._id)
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class SlurmBackend(Backend):
|
|
117
|
+
"""Implements the FlexLock backend for Slurm job submission."""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
folder: Path,
|
|
122
|
+
startup_lines: list[str],
|
|
123
|
+
configure_logging: bool = True,
|
|
124
|
+
python_exe="python",
|
|
125
|
+
):
|
|
126
|
+
self.folder = folder
|
|
127
|
+
self.folder.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
self.startup_lines = startup_lines
|
|
129
|
+
self.configure_logging = configure_logging
|
|
130
|
+
self.python_exe = python_exe
|
|
131
|
+
|
|
132
|
+
def render_script(self, pickled_path: "Path | str | None" = None) -> str:
|
|
133
|
+
"""Render the would-be Slurm script without submitting.
|
|
134
|
+
|
|
135
|
+
``pickled_path`` defaults to a placeholder path so users can preview
|
|
136
|
+
the script (e.g. via ``dry_run=True``) before any pickling happens.
|
|
137
|
+
"""
|
|
138
|
+
return self._make_script(Path(pickled_path or "<pickled-task.pkl>"))
|
|
139
|
+
|
|
140
|
+
def _make_script(self, pickled_path: Path) -> str:
|
|
141
|
+
"""Generates the Slurm submission script content."""
|
|
142
|
+
lines = ["#!/bin/bash"]
|
|
143
|
+
# SBATCH directives must all come before any shell commands —
|
|
144
|
+
# Slurm stops parsing directives at the first non-comment, non-blank line.
|
|
145
|
+
if self.configure_logging:
|
|
146
|
+
lines.extend(
|
|
147
|
+
[
|
|
148
|
+
f"#SBATCH --output={self.folder.absolute() / 'slurm.out'}",
|
|
149
|
+
f"#SBATCH --error={self.folder.absolute() / 'slurm.err'}",
|
|
150
|
+
]
|
|
151
|
+
)
|
|
152
|
+
lines.extend(self.startup_lines)
|
|
153
|
+
|
|
154
|
+
python_script = [
|
|
155
|
+
"import cloudpickle, sys, os",
|
|
156
|
+
# Make the submission directory importable so that _target_ strings
|
|
157
|
+
# like 'train.main' resolve in the worker as they do on the
|
|
158
|
+
# submitting machine. Append (not insert-at-0): cwd is a fallback
|
|
159
|
+
# only — it must never shadow installed packages, or a local file
|
|
160
|
+
# colliding with a dependency torch/lightning imports lazily during
|
|
161
|
+
# CUDA init can break the GPU stack in confusing ways.
|
|
162
|
+
f"sys.path.append({str(Path.cwd().resolve())!r})",
|
|
163
|
+
f"with open('{pickled_path}', 'rb') as f:",
|
|
164
|
+
" data = cloudpickle.load(f)",
|
|
165
|
+
" fn, a, kw = data",
|
|
166
|
+
"fn(*a, **kw)",
|
|
167
|
+
]
|
|
168
|
+
python_code = "\n".join(python_script)
|
|
169
|
+
lines.extend(
|
|
170
|
+
[
|
|
171
|
+
f"{self.python_exe} - <<'PY'\n{python_code}\nPY",
|
|
172
|
+
]
|
|
173
|
+
)
|
|
174
|
+
return "\n".join(lines)
|
|
175
|
+
|
|
176
|
+
def submit(self, fn, *args, **kwargs):
|
|
177
|
+
"""Submits a single function for execution as a Slurm job."""
|
|
178
|
+
data = (fn, args, kwargs)
|
|
179
|
+
pkl_path = self.folder / f"task_{secrets.token_hex(4)}.pkl"
|
|
180
|
+
with open(pkl_path, "wb") as f:
|
|
181
|
+
cloudpickle.dump(data, f)
|
|
182
|
+
|
|
183
|
+
script_path = self.folder / f"job_{secrets.token_hex(4)}.slurm"
|
|
184
|
+
script = self._make_script(pkl_path)
|
|
185
|
+
script_path.write_text(script)
|
|
186
|
+
|
|
187
|
+
for w in validate_slurm_script(script):
|
|
188
|
+
logger.warning(f"Slurm config: {w}")
|
|
189
|
+
|
|
190
|
+
out = subprocess.check_output(["sbatch", str(script_path)], text=True).strip()
|
|
191
|
+
job_id = out.split()[-1]
|
|
192
|
+
return SlurmJob(job_id, backend=self)
|
|
193
|
+
|
|
194
|
+
def check_status(self, job_id: str) -> str:
|
|
195
|
+
"""
|
|
196
|
+
Check the status of a Slurm job.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Status string: 'PENDING', 'RUNNING', 'COMPLETED', 'FAILED', 'CANCELLED', or 'unknown'
|
|
200
|
+
"""
|
|
201
|
+
try:
|
|
202
|
+
# Use squeue for running/pending jobs
|
|
203
|
+
out = subprocess.check_output(
|
|
204
|
+
["squeue", "-j", job_id, "-h", "-o", "%T"],
|
|
205
|
+
text=True,
|
|
206
|
+
stderr=subprocess.DEVNULL,
|
|
207
|
+
)
|
|
208
|
+
status = out.strip()
|
|
209
|
+
if status:
|
|
210
|
+
return status
|
|
211
|
+
except subprocess.CalledProcessError:
|
|
212
|
+
pass # Job not in queue, check sacct
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
# Use sacct for completed jobs
|
|
216
|
+
out = subprocess.check_output(
|
|
217
|
+
["sacct", "-j", job_id, "-n", "-o", "State"],
|
|
218
|
+
text=True,
|
|
219
|
+
stderr=subprocess.DEVNULL,
|
|
220
|
+
)
|
|
221
|
+
status = out.strip().split("\n")[0].strip()
|
|
222
|
+
if status:
|
|
223
|
+
return status
|
|
224
|
+
except subprocess.CalledProcessError:
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
logger.warning(f"Could not determine status for Slurm job {job_id}")
|
|
228
|
+
return "unknown"
|
|
229
|
+
|
|
230
|
+
# States that mean the job has definitively ended. Anything else —
|
|
231
|
+
# active (PENDING/RUNNING/...) OR ambiguous ("unknown") — is NOT terminal.
|
|
232
|
+
TERMINAL_STATES = frozenset({
|
|
233
|
+
"COMPLETED", "FAILED", "TIMEOUT", "CANCELLED", "NODE_FAIL",
|
|
234
|
+
"PREEMPTED", "OUT_OF_MEMORY", "BOOT_FAIL", "DEADLINE", "REVOKED",
|
|
235
|
+
"SPECIAL_EXIT",
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
def is_terminal(self, job_id: str) -> bool:
|
|
239
|
+
"""Return True only when the scheduler confirms the job has ended.
|
|
240
|
+
|
|
241
|
+
Used by the controller to decide whether tasks still marked
|
|
242
|
+
``running``/``pending`` have been orphaned by a dead job. This is
|
|
243
|
+
deliberately conservative: ``unknown`` (e.g. ``squeue`` doesn't list a
|
|
244
|
+
just-submitted job yet and ``sacct`` hasn't recorded it) and all
|
|
245
|
+
active states return ``False``, so a transient/racy status read never
|
|
246
|
+
causes a healthy job to be reconciled away.
|
|
247
|
+
"""
|
|
248
|
+
raw = self.check_status(job_id).strip()
|
|
249
|
+
if not raw:
|
|
250
|
+
return False
|
|
251
|
+
# Normalize forms like "CANCELLED by 12345" / "CANCELLED+".
|
|
252
|
+
token = raw.upper().split()[0].rstrip("+")
|
|
253
|
+
return token in self.TERMINAL_STATES
|
|
254
|
+
|
|
255
|
+
def wait_for_job(self, job_id: str, timeout=None, poll_interval=5) -> bool:
|
|
256
|
+
"""
|
|
257
|
+
Wait for a Slurm job to complete.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
job_id: Slurm job identifier
|
|
261
|
+
timeout: Maximum time to wait in seconds (None for no timeout)
|
|
262
|
+
poll_interval: Time between status checks in seconds
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
True if job completed successfully, False otherwise
|
|
266
|
+
"""
|
|
267
|
+
start_time = time.time()
|
|
268
|
+
logger.info(f"Waiting for Slurm job {job_id} to complete...")
|
|
269
|
+
|
|
270
|
+
while True:
|
|
271
|
+
status = self.check_status(job_id)
|
|
272
|
+
|
|
273
|
+
# Completed states
|
|
274
|
+
if status in ["COMPLETED", "completed"]:
|
|
275
|
+
logger.info(f"Slurm job {job_id} completed")
|
|
276
|
+
return True
|
|
277
|
+
|
|
278
|
+
# Failed states
|
|
279
|
+
if status in [
|
|
280
|
+
"FAILED",
|
|
281
|
+
"TIMEOUT",
|
|
282
|
+
"CANCELLED",
|
|
283
|
+
"NODE_FAIL",
|
|
284
|
+
"PREEMPTED",
|
|
285
|
+
"OUT_OF_MEMORY",
|
|
286
|
+
]:
|
|
287
|
+
logger.error(f"Slurm job {job_id} failed with status: {status}")
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
# Check timeout
|
|
291
|
+
if timeout and (time.time() - start_time) > timeout:
|
|
292
|
+
logger.error(f"Slurm job {job_id} timed out after {timeout}s")
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
# Still running or pending
|
|
296
|
+
if status in ["PENDING", "RUNNING", "CONFIGURING"]:
|
|
297
|
+
logger.debug(f"Slurm job {job_id} status: {status}")
|
|
298
|
+
else:
|
|
299
|
+
logger.debug(f"Slurm job {job_id} unknown status: {status}")
|
|
300
|
+
|
|
301
|
+
time.sleep(poll_interval)
|
|
302
|
+
|
|
303
|
+
def cancel_job(self, job_id: str) -> bool:
|
|
304
|
+
"""
|
|
305
|
+
Cancel a Slurm job.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
job_id: Slurm job identifier
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
True if cancellation succeeded, False otherwise
|
|
312
|
+
"""
|
|
313
|
+
try:
|
|
314
|
+
subprocess.check_call(["scancel", job_id], stderr=subprocess.DEVNULL)
|
|
315
|
+
logger.info(f"Cancelled Slurm job {job_id}")
|
|
316
|
+
return True
|
|
317
|
+
except subprocess.CalledProcessError as e:
|
|
318
|
+
logger.error(f"Failed to cancel Slurm job {job_id}: {e}")
|
|
319
|
+
return False
|
|
320
|
+
|
|
321
|
+
def environment(self):
|
|
322
|
+
"""Returns a JobEnvironment object providing Slurm-specific environment variables."""
|
|
323
|
+
|
|
324
|
+
class Env(JobEnvironment):
|
|
325
|
+
@property
|
|
326
|
+
def global_rank(self):
|
|
327
|
+
return int(os.getenv("SLURM_PROCID", 0))
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def world_size(self):
|
|
331
|
+
return int(os.getenv("SLURM_NTASKS", 1))
|
|
332
|
+
|
|
333
|
+
return Env()
|