outerloop-science 0.1.0.dev2__py3-none-any.whl → 0.1.0.dev4__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.
- outerloop/__init__.py +2 -2
- outerloop/appauth.py +17 -0
- outerloop/attempt.py +376 -101
- outerloop/brief.py +38 -25
- outerloop/cli.py +104 -6
- outerloop/climbboard.py +67 -22
- outerloop/compute.py +148 -53
- outerloop/contract.py +8 -0
- outerloop/dispatch.py +63 -18
- outerloop/evalcache.py +147 -0
- outerloop/followup.py +40 -25
- outerloop/github.py +67 -22
- outerloop/harness.py +22 -47
- outerloop/housekeeping.py +1 -17
- outerloop/image.py +0 -4
- outerloop/init.py +45 -2
- outerloop/intake.py +4 -7
- outerloop/launchlog.py +239 -0
- outerloop/maintain.py +353 -0
- outerloop/maintain_agent_cli.py +81 -0
- outerloop/maintain_post_cli.py +140 -0
- outerloop/measure.py +6 -0
- outerloop/orchestrator.py +141 -31
- outerloop/panel.py +3 -3
- outerloop/review.py +4 -0
- outerloop/review_agent.py +7 -7
- outerloop/review_agent_cli.py +2 -2
- outerloop/review_post_cli.py +2 -2
- outerloop/review_summarize_cli.py +8 -6
- outerloop/roles.py +27 -0
- outerloop/rolespec.py +3 -1
- outerloop/steward.py +7 -14
- outerloop/syscall.py +261 -47
- outerloop/syscall_cli.py +243 -12
- outerloop/tick.py +274 -313
- outerloop/verify_agent.py +8 -6
- outerloop/verify_post_cli.py +2 -2
- outerloop/watcher.py +203 -0
- {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev4.dist-info}/METADATA +4 -1
- outerloop_science-0.1.0.dev4.dist-info/RECORD +59 -0
- outerloop_science-0.1.0.dev2.dist-info/RECORD +0 -53
- {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev4.dist-info}/WHEEL +0 -0
- {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev4.dist-info}/entry_points.txt +0 -0
- {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev4.dist-info}/licenses/LICENSE +0 -0
- {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev4.dist-info}/licenses/NOTICE +0 -0
outerloop/compute.py
CHANGED
|
@@ -18,6 +18,7 @@ from __future__ import annotations
|
|
|
18
18
|
import contextlib
|
|
19
19
|
import logging
|
|
20
20
|
import os
|
|
21
|
+
import re
|
|
21
22
|
import shlex
|
|
22
23
|
import signal
|
|
23
24
|
import subprocess
|
|
@@ -67,7 +68,9 @@ Runner = Callable[[Sequence[str], int], CommandResult]
|
|
|
67
68
|
|
|
68
69
|
|
|
69
70
|
def _subprocess_runner(argv: Sequence[str], timeout_s: int) -> CommandResult:
|
|
70
|
-
completed = subprocess.run(
|
|
71
|
+
completed = subprocess.run(
|
|
72
|
+
list(argv), capture_output=True, text=True, timeout=timeout_s, check=False
|
|
73
|
+
)
|
|
71
74
|
return CommandResult(completed.returncode, completed.stdout, completed.stderr)
|
|
72
75
|
|
|
73
76
|
|
|
@@ -91,14 +94,16 @@ class JobSpec:
|
|
|
91
94
|
mem: str = "2G"
|
|
92
95
|
gpus: int = 0
|
|
93
96
|
qos: str = ""
|
|
97
|
+
# a positive nice LOWERS priority (Slurm, like Unix): experiments yield to
|
|
98
|
+
# the kernel's own evals and re-measures when a slot frees
|
|
99
|
+
nice: int = 0
|
|
94
100
|
output: str = "/dev/null"
|
|
95
101
|
# Slurm scheduling controls
|
|
96
102
|
dependency: str = "" # e.g. "afterany:12345" or "singleton"
|
|
97
103
|
begin: str = "" # e.g. "now+30" or an absolute "YYYY-MM-DDTHH:MM:SS"
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
|
|
101
|
-
hold: bool = False
|
|
104
|
+
# a job array: "0-15%4" runs tasks 0..15, at most 4 at a time; the queue
|
|
105
|
+
# holds one entry and squeue names its tasks `<id>_<k>`
|
|
106
|
+
array: str = ""
|
|
102
107
|
extra: tuple[str, ...] = ()
|
|
103
108
|
|
|
104
109
|
def to_argv(self) -> list[str]:
|
|
@@ -125,12 +130,14 @@ class JobSpec:
|
|
|
125
130
|
argv.append(f"--gpus-per-node={self.gpus}")
|
|
126
131
|
if self.qos:
|
|
127
132
|
argv.append(f"--qos={self.qos}")
|
|
128
|
-
if self.
|
|
129
|
-
argv.append("--
|
|
133
|
+
if self.nice:
|
|
134
|
+
argv.append(f"--nice={self.nice}")
|
|
130
135
|
if self.dependency:
|
|
131
136
|
argv.append(f"--dependency={self.dependency}")
|
|
132
137
|
if self.begin:
|
|
133
138
|
argv.append(f"--begin={self.begin}")
|
|
139
|
+
if self.array:
|
|
140
|
+
argv.append(f"--array={self.array}")
|
|
134
141
|
argv.extend(self.extra)
|
|
135
142
|
if self.command:
|
|
136
143
|
argv.append(f"--wrap={self.command}")
|
|
@@ -140,9 +147,19 @@ class JobSpec:
|
|
|
140
147
|
return argv
|
|
141
148
|
|
|
142
149
|
|
|
143
|
-
# `reason` and `
|
|
144
|
-
#
|
|
145
|
-
QUEUE_FIELDS = (
|
|
150
|
+
# `reason`, `gres` and `limit` feed the queue view: why a job waits, what it holds
|
|
151
|
+
# waits, whether it holds GPUs); the board reads the first six by key
|
|
152
|
+
QUEUE_FIELDS = (
|
|
153
|
+
"id",
|
|
154
|
+
"name",
|
|
155
|
+
"state",
|
|
156
|
+
"elapsed",
|
|
157
|
+
"partition",
|
|
158
|
+
"submitted",
|
|
159
|
+
"reason",
|
|
160
|
+
"gres",
|
|
161
|
+
"limit",
|
|
162
|
+
)
|
|
146
163
|
|
|
147
164
|
|
|
148
165
|
class Compute(Protocol):
|
|
@@ -155,9 +172,9 @@ class Compute(Protocol):
|
|
|
155
172
|
def job_partition(self, job_id: str) -> str: ...
|
|
156
173
|
def active_job_names(self) -> list[str]: ...
|
|
157
174
|
def queue_snapshot(self) -> list[dict[str, str]]: ...
|
|
175
|
+
def lane_load(self, partition: str) -> dict[str, int]: ...
|
|
158
176
|
def job_id_for_name(self, name: str) -> str: ...
|
|
159
|
-
def cancel(self, job_id: str) ->
|
|
160
|
-
def release(self, job_id: str) -> None: ...
|
|
177
|
+
def cancel(self, job_id: str) -> bool: ...
|
|
161
178
|
|
|
162
179
|
|
|
163
180
|
def local_mode() -> bool:
|
|
@@ -174,6 +191,47 @@ def compute_from_env() -> SlurmCompute | LocalCompute:
|
|
|
174
191
|
return LocalCompute() if local_mode() else SlurmCompute()
|
|
175
192
|
|
|
176
193
|
|
|
194
|
+
_JOB_ID = re.compile(r"^\d+(_\d+)?$") # a job, or one task of a job array (`<id>_<k>`)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _check_job_id(job_id: str) -> None:
|
|
198
|
+
if not _JOB_ID.match(job_id):
|
|
199
|
+
raise ValueError(f"not a job id: {job_id!r}")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def array_indices(spec: str) -> list[int]:
|
|
203
|
+
"""The task indices of an array spec: "0-15%4" -> 0..15 (the %K throttle
|
|
204
|
+
is the scheduler's concern); "" -> none."""
|
|
205
|
+
body = spec.split("%", 1)[0].strip()
|
|
206
|
+
if not body:
|
|
207
|
+
return []
|
|
208
|
+
lo, sep, hi = body.partition("-")
|
|
209
|
+
if not sep:
|
|
210
|
+
return [int(lo)] if lo.isdigit() else []
|
|
211
|
+
if not (lo.isdigit() and hi.isdigit()):
|
|
212
|
+
return []
|
|
213
|
+
return list(range(int(lo), int(hi) + 1))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def combine_states(states: Sequence[str]) -> str:
|
|
217
|
+
"""One state for a job array from its tasks' states: running while any
|
|
218
|
+
task runs, pending while any task waits, terminal only when every task
|
|
219
|
+
is — COMPLETED if all are, else the first other terminal state (FAILED,
|
|
220
|
+
TIMEOUT, CANCELLED...), so a sweep with one dead task reads as failed."""
|
|
221
|
+
if len(states) == 1:
|
|
222
|
+
return states[0]
|
|
223
|
+
for want in ("RUNNING", "COMPLETING"):
|
|
224
|
+
if any(s.startswith(want) for s in states):
|
|
225
|
+
return want
|
|
226
|
+
if any(is_pending(s) for s in states):
|
|
227
|
+
return "PENDING"
|
|
228
|
+
live = [s for s in states if not is_terminal(s)]
|
|
229
|
+
if live:
|
|
230
|
+
return live[0]
|
|
231
|
+
bad = [s for s in states if not s.startswith("COMPLETED")]
|
|
232
|
+
return bad[0] if bad else "COMPLETED"
|
|
233
|
+
|
|
234
|
+
|
|
177
235
|
@dataclass
|
|
178
236
|
class SlurmCompute:
|
|
179
237
|
"""The three verbs, plus afterany for wake jobs."""
|
|
@@ -195,8 +253,7 @@ class SlurmCompute:
|
|
|
195
253
|
def status(self, job_id: str) -> str:
|
|
196
254
|
"""The job's Slurm state, or GONE when a *successful* query finds no
|
|
197
255
|
record. Raises SlurmQueryError when the query itself fails."""
|
|
198
|
-
|
|
199
|
-
raise ValueError(f"not a job id: {job_id!r}")
|
|
256
|
+
_check_job_id(job_id)
|
|
200
257
|
try:
|
|
201
258
|
result = self.runner(
|
|
202
259
|
["sacct", "-j", job_id, "--parsable2", "--noheader", "-X", "-o", "State"],
|
|
@@ -206,14 +263,14 @@ class SlurmCompute:
|
|
|
206
263
|
raise SlurmQueryError(f"sacct did not run: {exc}") from exc
|
|
207
264
|
if result.returncode != 0:
|
|
208
265
|
raise SlurmQueryError(f"sacct failed ({result.returncode}): {result.stderr.strip()}")
|
|
209
|
-
|
|
210
|
-
|
|
266
|
+
# a job array answers one line per task; the array's state is theirs combined
|
|
267
|
+
states = [ln.strip() for ln in result.stdout.splitlines() if ln.strip()]
|
|
268
|
+
return combine_states(states) if states else GONE
|
|
211
269
|
|
|
212
270
|
def elapsed_seconds(self, job_id: str) -> int | None:
|
|
213
271
|
"""How long the job actually ran (sacct Elapsed), or None when sacct
|
|
214
272
|
has no record. Raises SlurmQueryError when the query itself fails."""
|
|
215
|
-
|
|
216
|
-
raise ValueError(f"not a job id: {job_id!r}")
|
|
273
|
+
_check_job_id(job_id)
|
|
217
274
|
try:
|
|
218
275
|
result = self.runner(
|
|
219
276
|
["sacct", "-j", job_id, "--parsable2", "--noheader", "-X", "-o", "Elapsed"],
|
|
@@ -230,8 +287,7 @@ class SlurmCompute:
|
|
|
230
287
|
"""Why a PENDING job is pending — Slurm's reason (`Dependency`,
|
|
231
288
|
`DependencyNeverSatisfied`, `Priority`, ...), or "" when squeue no
|
|
232
289
|
longer lists it. Raises SlurmQueryError when the query itself fails."""
|
|
233
|
-
|
|
234
|
-
raise ValueError(f"not a job id: {job_id!r}")
|
|
290
|
+
_check_job_id(job_id)
|
|
235
291
|
try:
|
|
236
292
|
result = self.runner(["squeue", "-j", job_id, "-h", "-o", "%r"], self.command_timeout_s)
|
|
237
293
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
@@ -245,8 +301,7 @@ class SlurmCompute:
|
|
|
245
301
|
them, or "" when squeue no longer lists it. A site can MOVE a pending
|
|
246
302
|
job off the partition it was submitted to (Torch does, under
|
|
247
303
|
congestion); callers compare this with what they asked for."""
|
|
248
|
-
|
|
249
|
-
raise ValueError(f"not a job id: {job_id!r}")
|
|
304
|
+
_check_job_id(job_id)
|
|
250
305
|
try:
|
|
251
306
|
result = self.runner(["squeue", "-j", job_id, "-h", "-o", "%P"], self.command_timeout_s)
|
|
252
307
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
@@ -277,7 +332,7 @@ class SlurmCompute:
|
|
|
277
332
|
on failure, like active_job_names."""
|
|
278
333
|
try:
|
|
279
334
|
result = self.runner(
|
|
280
|
-
["squeue", "--me", "--noheader", "-o", "%i|%j|%T|%M|%P|%V|%r|%b"],
|
|
335
|
+
["squeue", "--me", "--noheader", "-o", "%i|%j|%T|%M|%P|%V|%r|%b|%l"],
|
|
281
336
|
self.command_timeout_s,
|
|
282
337
|
)
|
|
283
338
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
@@ -291,6 +346,30 @@ class SlurmCompute:
|
|
|
291
346
|
rows.append(dict(zip(QUEUE_FIELDS, parts, strict=True)))
|
|
292
347
|
return rows
|
|
293
348
|
|
|
349
|
+
def lane_load(self, partition: str) -> dict[str, int]:
|
|
350
|
+
"""Node counts by state on a lane (a partition or a comma-separated
|
|
351
|
+
list), from sinfo: {"idle": 3, "mixed": 20, "allocated": 11}. Context
|
|
352
|
+
for the queue view, nothing a caller acts on. Empty when no lane is
|
|
353
|
+
named; raises SlurmQueryError on a failed query."""
|
|
354
|
+
if not partition:
|
|
355
|
+
return {}
|
|
356
|
+
try:
|
|
357
|
+
result = self.runner(
|
|
358
|
+
["sinfo", "--noheader", "-p", partition, "-o", "%T %D"], self.command_timeout_s
|
|
359
|
+
)
|
|
360
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
361
|
+
raise SlurmQueryError(f"sinfo did not run: {exc}") from exc
|
|
362
|
+
if result.returncode != 0:
|
|
363
|
+
raise SlurmQueryError(f"sinfo failed ({result.returncode}): {result.stderr.strip()}")
|
|
364
|
+
load: dict[str, int] = {}
|
|
365
|
+
for line in result.stdout.splitlines():
|
|
366
|
+
parts = line.split()
|
|
367
|
+
if len(parts) != 2 or not parts[1].isdigit():
|
|
368
|
+
continue
|
|
369
|
+
state = parts[0].rstrip("*~#!%$@^-") # sinfo's state flags (draining, no-respond...)
|
|
370
|
+
load[state] = load.get(state, 0) + int(parts[1])
|
|
371
|
+
return load
|
|
372
|
+
|
|
294
373
|
def job_id_for_name(self, name: str) -> str:
|
|
295
374
|
"""The id of this user's PENDING/RUNNING job with exactly `name`, or
|
|
296
375
|
"" if none. Authoritative for "is this still live" independent of any
|
|
@@ -309,22 +388,16 @@ class SlurmCompute:
|
|
|
309
388
|
ids = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
310
389
|
return ids[0] if ids else ""
|
|
311
390
|
|
|
312
|
-
def cancel(self, job_id: str) ->
|
|
313
|
-
"""Cancel; idempotent (cancelling a finished job is not an error).
|
|
314
|
-
|
|
315
|
-
|
|
391
|
+
def cancel(self, job_id: str) -> bool:
|
|
392
|
+
"""Cancel; idempotent (cancelling a finished job is not an error).
|
|
393
|
+
False when scancel itself failed, so a caller that must know (the
|
|
394
|
+
sweep's cancel-on-end) can try again; most callers are best-effort."""
|
|
395
|
+
_check_job_id(job_id)
|
|
316
396
|
result = self.runner(["scancel", job_id], self.command_timeout_s)
|
|
317
397
|
if result.returncode != 0:
|
|
318
398
|
log.warning("scancel %s: %s", job_id, result.stderr.strip())
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
"""Release a job submitted with `hold` so the scheduler may start it.
|
|
322
|
-
Releasing a job that is not held is not an error."""
|
|
323
|
-
if not job_id.isdigit():
|
|
324
|
-
raise ValueError(f"not a job id: {job_id!r}")
|
|
325
|
-
result = self.runner(["scontrol", "release", job_id], self.command_timeout_s)
|
|
326
|
-
if result.returncode != 0:
|
|
327
|
-
log.warning("scontrol release %s: %s", job_id, result.stderr.strip())
|
|
399
|
+
return False
|
|
400
|
+
return True
|
|
328
401
|
|
|
329
402
|
|
|
330
403
|
# Local job ids start far above any real Slurm id so the two can never be
|
|
@@ -389,6 +462,33 @@ class LocalCompute:
|
|
|
389
462
|
if k in ("PATH", "HOME", "LANG", "TMPDIR", "SLURM_TMPDIR", "USER", "LOGNAME")
|
|
390
463
|
or (k.startswith(("OUTERLOOP_", "REVIEW_HERMES_")) and not _secret_name(k))
|
|
391
464
|
}
|
|
465
|
+
indices = array_indices(spec.array)
|
|
466
|
+
if indices:
|
|
467
|
+
# a job array runs its tasks in turn — there is no queue here to
|
|
468
|
+
# throttle; each task keeps its own state and output under
|
|
469
|
+
# `<id>_<k>`, and the array's own state is theirs combined
|
|
470
|
+
states = [
|
|
471
|
+
self._run_and_record(
|
|
472
|
+
spec, argv, {**job_env, "SLURM_ARRAY_TASK_ID": str(i)}, f"{job_id}_{i}"
|
|
473
|
+
)
|
|
474
|
+
for i in indices
|
|
475
|
+
]
|
|
476
|
+
state = combine_states(states)
|
|
477
|
+
self._record(spec, job_id, state, "")
|
|
478
|
+
else:
|
|
479
|
+
state = self._run_and_record(spec, argv, job_env, job_id)
|
|
480
|
+
state_dir = _local_state_dir()
|
|
481
|
+
where = (
|
|
482
|
+
f"; output in {state_dir / (job_id + '.out')}"
|
|
483
|
+
if state_dir and state != "COMPLETED"
|
|
484
|
+
else ""
|
|
485
|
+
)
|
|
486
|
+
log.info("ran %s locally as job %s: %s%s", spec.job_name, job_id, state, where)
|
|
487
|
+
return job_id
|
|
488
|
+
|
|
489
|
+
def _run_and_record(
|
|
490
|
+
self, spec: JobSpec, argv: list[str], job_env: dict[str, str], job_id: str
|
|
491
|
+
) -> str:
|
|
392
492
|
try:
|
|
393
493
|
# the job runs in its OWN session (= process group), so the
|
|
394
494
|
# walltime kill takes the whole tree — a job script waiting on
|
|
@@ -425,6 +525,10 @@ class LocalCompute:
|
|
|
425
525
|
"local job %s: an escaped child survived the walltime kill", spec.job_name
|
|
426
526
|
)
|
|
427
527
|
state = "TIMEOUT"
|
|
528
|
+
self._record(spec, job_id, state, output)
|
|
529
|
+
return state
|
|
530
|
+
|
|
531
|
+
def _record(self, spec: JobSpec, job_id: str, state: str, output: str) -> None:
|
|
428
532
|
state_dir = _local_state_dir()
|
|
429
533
|
if state_dir is not None:
|
|
430
534
|
try:
|
|
@@ -453,22 +557,14 @@ class LocalCompute:
|
|
|
453
557
|
log.warning("local job %s: state persist failed: %s", spec.job_name, exc)
|
|
454
558
|
if spec.output and spec.output != "/dev/null":
|
|
455
559
|
try:
|
|
456
|
-
with open(spec.output, "w") as fh:
|
|
560
|
+
with open(spec.output, "a" if "_" in job_id else "w") as fh:
|
|
457
561
|
fh.write(output)
|
|
458
562
|
except OSError as exc:
|
|
459
563
|
log.warning("local job %s: output write failed: %s", spec.job_name, exc)
|
|
460
564
|
self._states[job_id] = state
|
|
461
|
-
where = (
|
|
462
|
-
f"; output in {state_dir / (job_id + '.out')}"
|
|
463
|
-
if state_dir and state != "COMPLETED"
|
|
464
|
-
else ""
|
|
465
|
-
)
|
|
466
|
-
log.info("ran %s locally as job %s: %s%s", spec.job_name, job_id, state, where)
|
|
467
|
-
return job_id
|
|
468
565
|
|
|
469
566
|
def status(self, job_id: str) -> str:
|
|
470
|
-
|
|
471
|
-
raise ValueError(f"not a job id: {job_id!r}")
|
|
567
|
+
_check_job_id(job_id)
|
|
472
568
|
state = self._states.get(job_id, "")
|
|
473
569
|
if state:
|
|
474
570
|
return state
|
|
@@ -494,16 +590,15 @@ class LocalCompute:
|
|
|
494
590
|
def queue_snapshot(self) -> list[dict[str, str]]:
|
|
495
591
|
return []
|
|
496
592
|
|
|
593
|
+
def lane_load(self, partition: str) -> dict[str, int]:
|
|
594
|
+
return {} # no lanes in the monolith
|
|
595
|
+
|
|
497
596
|
def job_id_for_name(self, name: str) -> str:
|
|
498
597
|
return ""
|
|
499
598
|
|
|
500
|
-
def cancel(self, job_id: str) ->
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
# already terminal; cancelling a finished job is not an error
|
|
504
|
-
|
|
505
|
-
def release(self, job_id: str) -> None:
|
|
506
|
-
"""Local jobs run synchronously at submit; nothing is ever held."""
|
|
599
|
+
def cancel(self, job_id: str) -> bool:
|
|
600
|
+
_check_job_id(job_id)
|
|
601
|
+
return True # already terminal; cancelling a finished job is not an error
|
|
507
602
|
|
|
508
603
|
|
|
509
604
|
def parse_elapsed(text: str) -> int | None:
|
outerloop/contract.py
CHANGED
|
@@ -272,6 +272,14 @@ class Budgets(_StrictModel):
|
|
|
272
272
|
# (agent-01..agent-0N), so branches, ledger rows, and reports stay
|
|
273
273
|
# distinct. runs_per_week and gpu_hours_per_run remain the spend guards.
|
|
274
274
|
max_active_attempts: int | None = Field(default=None, ge=1)
|
|
275
|
+
# The pace ceiling for an author's sweeps, in GPUs: one launch may hold at
|
|
276
|
+
# most this many at once, so a sweep of N tasks runs
|
|
277
|
+
# max_concurrent_gpus // gpus of them at a time (`--array=0-N%K`). Lenient
|
|
278
|
+
# by design — not the cap divided by the agent count (agents rarely launch
|
|
279
|
+
# at the same moment, and an idle share is wasted GPU); on a 16-GPU cap 12
|
|
280
|
+
# lets one sweep use most of the machine while a sibling's job still gets
|
|
281
|
+
# in. Unset: the author's own pace, the whole array by default.
|
|
282
|
+
max_concurrent_gpus: int | None = Field(default=None, ge=1)
|
|
275
283
|
|
|
276
284
|
@model_validator(mode="before")
|
|
277
285
|
@classmethod
|
outerloop/dispatch.py
CHANGED
|
@@ -36,8 +36,14 @@ from pathlib import Path
|
|
|
36
36
|
from uuid import uuid4
|
|
37
37
|
|
|
38
38
|
from outerloop.compute import JobSpec
|
|
39
|
-
from outerloop.github import
|
|
40
|
-
|
|
39
|
+
from outerloop.github import (
|
|
40
|
+
SAFE_GIT_FLAGS,
|
|
41
|
+
GitError,
|
|
42
|
+
Workspace,
|
|
43
|
+
ensure_regular_git_dir,
|
|
44
|
+
git_identity,
|
|
45
|
+
)
|
|
46
|
+
from outerloop.orchestrator import EvalError, managed_eval_env, metric_from_output
|
|
41
47
|
|
|
42
48
|
log = logging.getLogger(__name__)
|
|
43
49
|
|
|
@@ -94,7 +100,11 @@ class Snapshot:
|
|
|
94
100
|
|
|
95
101
|
|
|
96
102
|
def snapshot_tree(
|
|
97
|
-
ws: Workspace,
|
|
103
|
+
ws: Workspace,
|
|
104
|
+
base_sha: str,
|
|
105
|
+
exclude: tuple[str, ...] = (),
|
|
106
|
+
force: tuple[str, ...] = (),
|
|
107
|
+
author: str = "",
|
|
98
108
|
) -> Snapshot:
|
|
99
109
|
"""Snapshot the workspace's current CONTENT as a commit parented on
|
|
100
110
|
`base_sha`, without touching the working index, and retain it under a
|
|
@@ -105,7 +115,8 @@ def snapshot_tree(
|
|
|
105
115
|
keeps it (docs/design/research-lines.md). `force` adds those paths even
|
|
106
116
|
when the target's ignore rules match them — the notebook seal uses it so
|
|
107
117
|
a .gitignore entry cannot silently discard session memory; callers pass
|
|
108
|
-
only paths that exist.
|
|
118
|
+
only paths that exist. `author` is the bot login the seal commit is
|
|
119
|
+
made as (empty: OUTERLOOP_BOT_LOGIN).
|
|
109
120
|
"""
|
|
110
121
|
# the snapshot writes an index, a tree, a commit, and a ref into this
|
|
111
122
|
# repository: a session-reshaped .git is refused first, like every other
|
|
@@ -163,10 +174,7 @@ def snapshot_tree(
|
|
|
163
174
|
commit = run(
|
|
164
175
|
[
|
|
165
176
|
*git,
|
|
166
|
-
|
|
167
|
-
"user.name=dispatch",
|
|
168
|
-
"-c",
|
|
169
|
-
"user.email=dispatch@localhost",
|
|
177
|
+
*git_identity(author),
|
|
170
178
|
"commit-tree",
|
|
171
179
|
tree,
|
|
172
180
|
"-p",
|
|
@@ -205,6 +213,7 @@ def _filter_neutral_env(base_git: list[str], env: dict[str, str]) -> dict[str, s
|
|
|
205
213
|
capture_output=True,
|
|
206
214
|
text=True,
|
|
207
215
|
timeout=30,
|
|
216
|
+
check=False,
|
|
208
217
|
)
|
|
209
218
|
# -z: NUL-separated records, each "key\nvalue" — so a value containing a
|
|
210
219
|
# newline can never masquerade as a second record.
|
|
@@ -281,9 +290,22 @@ def write_eval_job(
|
|
|
281
290
|
artifacts: tuple[str, ...] = (),
|
|
282
291
|
artifact_max_bytes: int = 0,
|
|
283
292
|
gpus: int = 0,
|
|
293
|
+
array: int = 1,
|
|
294
|
+
seed_cache: Path | None = None,
|
|
284
295
|
) -> Path:
|
|
285
296
|
"""Write the orchestrator-authored job script for one dispatched eval.
|
|
286
297
|
|
|
298
|
+
`seed_cache` names the kernel-warmed seed for this target
|
|
299
|
+
(docs/design/eval-cache.md): the job copies its contents into its own
|
|
300
|
+
scratch cache before `uv` runs — a copy, never a bind or a hardlink, so the
|
|
301
|
+
job's cache is its own and the seed is never written; a missing seed or a
|
|
302
|
+
failed copy costs a download, never the eval.
|
|
303
|
+
|
|
304
|
+
`array` > 1 writes ONE script for a Slurm job array: each task derives its
|
|
305
|
+
own job dir `eval-<name>.<k>` from SLURM_ARRAY_TASK_ID (SWEEP_INDEX under a
|
|
306
|
+
local run) and sees the index as SWEEP_INDEX; the task dirs are created
|
|
307
|
+
and cleared here, the script lives under `eval-<name>/`.
|
|
308
|
+
|
|
287
309
|
`gpus` > 0 adds `--nv` to the jail so the job's allocated GPUs (the
|
|
288
310
|
JobSpec requests them) are visible inside the container; nothing else
|
|
289
311
|
about the containment changes.
|
|
@@ -310,13 +332,16 @@ def write_eval_job(
|
|
|
310
332
|
"""
|
|
311
333
|
ev = run_dir / f"eval-{name}"
|
|
312
334
|
ev.mkdir(parents=True, exist_ok=True)
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
335
|
+
task_dirs = [run_dir / f"eval-{name}.{k}" for k in range(array)] if array > 1 else [ev]
|
|
336
|
+
for task_dir in task_dirs:
|
|
337
|
+
task_dir.mkdir(parents=True, exist_ok=True)
|
|
338
|
+
# a resubmitted eval must never be read as its predecessor: every prior
|
|
339
|
+
# artifact — including a leftover extracted tree — goes before submission
|
|
340
|
+
for stale in ("exit-code", "stdout", "stderr", "setup.log", "submitted", "artifacts.log"):
|
|
341
|
+
(task_dir / stale).unlink(missing_ok=True)
|
|
342
|
+
shutil.rmtree(task_dir / "tree", ignore_errors=True)
|
|
343
|
+
shutil.rmtree(task_dir / "artifacts", ignore_errors=True)
|
|
344
|
+
(task_dir / "command.txt").write_text(command)
|
|
320
345
|
# extra_env matches the in-job evaluator's contract: managed keys (HOME,
|
|
321
346
|
# UV_*, PATH...) are DROPPED, never allowed to override the isolation, and
|
|
322
347
|
# keys must be shell-identifier shaped (they are exported unquoted).
|
|
@@ -330,10 +355,20 @@ def write_eval_job(
|
|
|
330
355
|
# as the snapshot, injected as GIT_CONFIG_* env (robust to '=' in a driver
|
|
331
356
|
# name, unlike -c) so a smudge filter cannot execute during checkout
|
|
332
357
|
neutral = _filter_neutral_env(["git", "-C", str(repo_root), *SAFE_GIT_FLAGS], {})
|
|
358
|
+
if array > 1:
|
|
359
|
+
# the task picks its own job dir; under Slurm the index is the array
|
|
360
|
+
# task id, under a local run the caller exports SWEEP_INDEX
|
|
361
|
+
ev_lines = [
|
|
362
|
+
'TASK="${SLURM_ARRAY_TASK_ID:-${SWEEP_INDEX:-0}}"',
|
|
363
|
+
f'EV={shlex.quote(str(ev))}."$TASK"',
|
|
364
|
+
'export SWEEP_INDEX="$TASK" APPTAINERENV_SWEEP_INDEX="$TASK"',
|
|
365
|
+
]
|
|
366
|
+
else:
|
|
367
|
+
ev_lines = [f"EV={shlex.quote(str(ev))}"]
|
|
333
368
|
lines = [
|
|
334
369
|
"#!/bin/sh",
|
|
335
370
|
"set -u",
|
|
336
|
-
|
|
371
|
+
*ev_lines,
|
|
337
372
|
f"REPO={shlex.quote(str(repo_root))}",
|
|
338
373
|
# the extracted tree lives on NODE-LOCAL scratch, not the shared run
|
|
339
374
|
# dir: it dies with the job (nothing to reap on the shared FS), and
|
|
@@ -361,6 +396,11 @@ def write_eval_job(
|
|
|
361
396
|
]
|
|
362
397
|
for k, v in neutral.items():
|
|
363
398
|
lines.append(f"export {k}={shlex.quote(v)}")
|
|
399
|
+
if seed_cache is not None:
|
|
400
|
+
q = shlex.quote(str(seed_cache))
|
|
401
|
+
lines.append(
|
|
402
|
+
f'if [ -d {q} ]; then cp -a {q}/. "$SCRATCH/cache"/ 2>> "$EV/setup.log" || true; fi'
|
|
403
|
+
)
|
|
364
404
|
lines += [
|
|
365
405
|
# Materialize the snapshot by CHECKOUT, not `git archive`: a checkout
|
|
366
406
|
# reproduces content faithfully — INCLUDING .gitattributes — and does
|
|
@@ -478,6 +518,8 @@ def eval_job_spec(
|
|
|
478
518
|
cpus: int = 4,
|
|
479
519
|
mem: str = "8G",
|
|
480
520
|
gpus: int = 0,
|
|
521
|
+
nice: int = 0,
|
|
522
|
+
array: str = "",
|
|
481
523
|
) -> JobSpec:
|
|
482
524
|
"""The JobSpec for one dispatched eval: the hint CLAMPED to our ceiling
|
|
483
525
|
plus setup slack — a contract value above EVAL_JOB_MINUTES_CEILING must
|
|
@@ -486,7 +528,8 @@ def eval_job_spec(
|
|
|
486
528
|
the GPU lane (DispatchSettings.placement) when it is nonzero, and the
|
|
487
529
|
job is sized for it: a GPU eval gets at least EVAL_CPUS_PER_GPU cores
|
|
488
530
|
and EVAL_MEM_GB_PER_GPU GB per GPU (a training eval's data loading and
|
|
489
|
-
torch.compile workers do not fit the CPU eval's 4 cores / 8 GB).
|
|
531
|
+
torch.compile workers do not fit the CPU eval's 4 cores / 8 GB). `nice`
|
|
532
|
+
lowers the job's priority below the kernel's evals (the launcher sets it)."""
|
|
490
533
|
if gpus > 0:
|
|
491
534
|
cpus = max(cpus, EVAL_CPUS_PER_GPU * gpus)
|
|
492
535
|
given = _mem_gb(mem)
|
|
@@ -503,6 +546,8 @@ def eval_job_spec(
|
|
|
503
546
|
cpus=cpus,
|
|
504
547
|
mem=mem,
|
|
505
548
|
gpus=gpus,
|
|
549
|
+
nice=nice,
|
|
550
|
+
array=array,
|
|
506
551
|
)
|
|
507
552
|
|
|
508
553
|
|
|
@@ -541,7 +586,7 @@ def read_eval_result(run_dir: Path, name: str, metric: str) -> float:
|
|
|
541
586
|
with contextlib.suppress(OSError, ValueError):
|
|
542
587
|
tail = (ev / "stderr").read_text(errors="replace")[-300:]
|
|
543
588
|
raise EvalError(f"dispatched eval {name} failed ({code}): {tail}")
|
|
544
|
-
value =
|
|
589
|
+
value = metric_from_output(stdout, metric)
|
|
545
590
|
if value is None:
|
|
546
591
|
raise EvalError(f"dispatched eval {name}: no readable {metric!r} in output")
|
|
547
592
|
if not math.isfinite(value):
|