simrun 0.1.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.
- simrun/__init__.py +3 -0
- simrun/argo.py +449 -0
- simrun/build_constraints.txt +20 -0
- simrun/cgroup.py +77 -0
- simrun/cli.py +350 -0
- simrun/client.py +407 -0
- simrun/config.py +63 -0
- simrun/cwl.py +287 -0
- simrun/demo.py +106 -0
- simrun/edit.py +408 -0
- simrun/environment.py +370 -0
- simrun/ledger.py +286 -0
- simrun/mcp.py +161 -0
- simrun/planner.py +303 -0
- simrun/plugins.py +149 -0
- simrun/plugins_builtin.py +33 -0
- simrun/podspec.py +166 -0
- simrun/resources.py +270 -0
- simrun/service/__init__.py +1 -0
- simrun/service/app.py +1721 -0
- simrun/service/argo_backend.py +842 -0
- simrun/service/auth.py +93 -0
- simrun/service/backend.py +170 -0
- simrun/service/compare.py +77 -0
- simrun/service/controller.py +788 -0
- simrun/service/edits.py +406 -0
- simrun/service/index.py +292 -0
- simrun/service/logs.py +91 -0
- simrun/service/submission.py +349 -0
- simrun/snapshot.py +196 -0
- simrun/store.py +190 -0
- simrun/testing.py +263 -0
- simrun/worker.py +1097 -0
- simrun/workflow.py +188 -0
- simrun-0.1.0.dist-info/METADATA +107 -0
- simrun-0.1.0.dist-info/RECORD +39 -0
- simrun-0.1.0.dist-info/WHEEL +4 -0
- simrun-0.1.0.dist-info/entry_points.txt +10 -0
- simrun-0.1.0.dist-info/licenses/LICENSE +7 -0
simrun/worker.py
ADDED
|
@@ -0,0 +1,1097 @@
|
|
|
1
|
+
"""The worker harness: a pod of a split task computes units of the ledger in a pool.
|
|
2
|
+
|
|
3
|
+
`simrun-worker` (`main`) claims units of the ledger of its task, computes them in a
|
|
4
|
+
local process pool with the functions of the task type and writes a report;
|
|
5
|
+
`simrun-merge` (`merge_main`) merges the units once the ledger is complete;
|
|
6
|
+
`simrun-exec` (`exec_main`) runs the command of an unsplit plugin task and writes a
|
|
7
|
+
report of it. A pod
|
|
8
|
+
is evicted with SIGTERM and killed after its grace period, and is restarted with the
|
|
9
|
+
same worker id, which releases the claims of the incarnation before.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import importlib
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import math
|
|
17
|
+
import multiprocessing
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import signal
|
|
21
|
+
import site
|
|
22
|
+
import socket
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
import uuid
|
|
29
|
+
from collections.abc import Generator, Mapping
|
|
30
|
+
from concurrent.futures import FIRST_COMPLETED, Future, ProcessPoolExecutor, wait
|
|
31
|
+
from concurrent.futures.process import BrokenProcessPool
|
|
32
|
+
from contextlib import contextmanager
|
|
33
|
+
from dataclasses import asdict, dataclass, field
|
|
34
|
+
from datetime import UTC, datetime
|
|
35
|
+
from multiprocessing import forkserver
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
from pydantic import BaseModel
|
|
39
|
+
|
|
40
|
+
from simrun import cgroup
|
|
41
|
+
from simrun.ledger import Ledger
|
|
42
|
+
from simrun.plugins import TaskPaths, TaskType, load_task_types, resolve
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
GIB = 2**30
|
|
47
|
+
UNITS_DIR = "units"
|
|
48
|
+
WORKERS_DIR = "workers"
|
|
49
|
+
PROGRESS_FILE = "progress.jsonl"
|
|
50
|
+
SETTINGS_FILE = "settings.json"
|
|
51
|
+
# the provenance of the sources of the run, `SIMRUN_PROVENANCE`
|
|
52
|
+
PROVENANCE_FILE = "provenance.json"
|
|
53
|
+
# written to the output of a task by `simrun-merge` after a successful merge, so the
|
|
54
|
+
# service knows a task is done also when the workflow is gone
|
|
55
|
+
MERGED_MARKER = ".simrun-merged"
|
|
56
|
+
DEFAULT_SCRATCH = "/scratch"
|
|
57
|
+
THREAD_VARIABLES = ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS")
|
|
58
|
+
POLL_S = 0.2
|
|
59
|
+
HEARTBEAT_S = 30.0
|
|
60
|
+
# seconds between two samples of the anonymous memory of the cgroup
|
|
61
|
+
SAMPLE_S = 1.0
|
|
62
|
+
# the directory of the writable trees the snapshots are installed from
|
|
63
|
+
SOURCES_DIR = "simrun-sources"
|
|
64
|
+
# seconds a command of `simrun-exec` gets after SIGTERM, the grace of a worker
|
|
65
|
+
GRACE_S = 60.0
|
|
66
|
+
COORDINATOR_POLL_S = 5.0
|
|
67
|
+
# a heartbeat is stale this long after the grace of a worker
|
|
68
|
+
STALE_MARGIN_S = 60.0
|
|
69
|
+
ROLES = {"base": True, "extra": False}
|
|
70
|
+
|
|
71
|
+
EXIT_DONE = 0
|
|
72
|
+
EXIT_ANALYSIS = 3
|
|
73
|
+
EXIT_CONFIGURATION = 4
|
|
74
|
+
# A process of the pool was killed, as the OOM killer of the cgroup does: the exit
|
|
75
|
+
# code of a killed pod, on which the workflow retries with more memory.
|
|
76
|
+
EXIT_POOL_BROKEN = 128 + signal.SIGKILL
|
|
77
|
+
# Evicted: not done, so the workflow retries the pod.
|
|
78
|
+
EXIT_TERMINATED = 128 + signal.SIGTERM
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ConfigurationError(Exception):
|
|
82
|
+
"""The environment or the settings of a worker are wrong."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class UnitError(RuntimeError):
|
|
86
|
+
"""A unit failed in the function of the task type; it was released."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, index: int) -> None:
|
|
89
|
+
"""Create the error of the unit `index`."""
|
|
90
|
+
super().__init__(f"unit {index} failed")
|
|
91
|
+
self.index = index
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Terminated(Exception):
|
|
95
|
+
"""The worker stopped on SIGTERM before its units were done."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class WorkerReport:
|
|
100
|
+
"""What a worker did, written as `task_dir/workers/<worker>.json`.
|
|
101
|
+
|
|
102
|
+
Attributes:
|
|
103
|
+
worker: The worker id.
|
|
104
|
+
node: The server the worker ran on.
|
|
105
|
+
processes: The number of processes of the pool.
|
|
106
|
+
units_done: The units the worker completed.
|
|
107
|
+
seconds: The duration of the worker.
|
|
108
|
+
peak_memory_bytes: The largest sample of the anonymous memory of the cgroup
|
|
109
|
+
(`MemorySampler`), without the page cache.
|
|
110
|
+
memory_limit_bytes: The memory limit of the cgroup.
|
|
111
|
+
per_worker_gb: The memory per process in GiB the worker was sized with.
|
|
112
|
+
oom_kills: The processes of its cgroup the out of memory killer killed while
|
|
113
|
+
it ran (`memory.events` `oom_kill`), the evidence that an exit 137 was a
|
|
114
|
+
kill for memory.
|
|
115
|
+
unit_seconds: The seconds of the units it completed, each measured in the
|
|
116
|
+
process which computed it (the setup of a process in its first unit),
|
|
117
|
+
summed: `unit_seconds / len(units_done)` is the time of a unit in one
|
|
118
|
+
process, without the waits of the worker (for the units of other
|
|
119
|
+
workers, a coordinator) and the processes idle at its end.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
worker: str
|
|
123
|
+
node: str
|
|
124
|
+
processes: int
|
|
125
|
+
units_done: list[int] = field(default_factory=list)
|
|
126
|
+
seconds: float = 0.0
|
|
127
|
+
peak_memory_bytes: int | None = None
|
|
128
|
+
memory_limit_bytes: int | None = None
|
|
129
|
+
per_worker_gb: float = 0.0
|
|
130
|
+
oom_kills: int = 0
|
|
131
|
+
unit_seconds: float = 0.0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def processes(
|
|
135
|
+
cores: int, memory_bytes: int | None, base_gb: float, per_worker_gb: float
|
|
136
|
+
) -> int:
|
|
137
|
+
"""The number of processes the cores and the memory allow.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
cores: The number of cores.
|
|
141
|
+
memory_bytes: The memory limit in bytes, `None` without a limit.
|
|
142
|
+
base_gb: The memory of the task besides its processes in GiB.
|
|
143
|
+
per_worker_gb: The memory per process in GiB.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
`min(cores, floor((memory - base) / per_worker))`, at least 1.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
ValueError: Not even one process fits into the memory.
|
|
150
|
+
"""
|
|
151
|
+
if memory_bytes is None:
|
|
152
|
+
return max(1, cores)
|
|
153
|
+
# the tolerance keeps an exact quotient from rounding down to the integer below
|
|
154
|
+
fit = math.floor((memory_bytes / GIB - base_gb) / per_worker_gb + 1e-9)
|
|
155
|
+
if fit < 1:
|
|
156
|
+
raise ValueError(
|
|
157
|
+
f"no process fits into {memory_bytes / GIB:.2f} GiB with "
|
|
158
|
+
f"{base_gb} GiB base and {per_worker_gb} GiB per process"
|
|
159
|
+
)
|
|
160
|
+
return max(1, min(cores, fit))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# The setups which ran in this process of the pool.
|
|
164
|
+
_SETUPS_DONE: set[str] = set()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _init_process() -> None:
|
|
168
|
+
"""Start a process of the pool: ignore SIGINT and SIGTERM.
|
|
169
|
+
|
|
170
|
+
A SIGTERM to the process group (from a container runtime or Argo's emissary)
|
|
171
|
+
reaches the worker only, which gives the running units their grace. The worker
|
|
172
|
+
ends the processes with SIGKILL, see `_kill`.
|
|
173
|
+
"""
|
|
174
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
175
|
+
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
|
176
|
+
# blocked by the forkserver, see `_start_forkserver`
|
|
177
|
+
signal.pthread_sigmask(signal.SIG_UNBLOCK, {signal.SIGTERM})
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _start_forkserver() -> None:
|
|
181
|
+
"""Start the forkserver of the pool with SIGTERM blocked.
|
|
182
|
+
|
|
183
|
+
The forkserver and the processes it forks inherit the mask, so a SIGTERM to the
|
|
184
|
+
process group ends neither. The forkserver would die of it, and the executor
|
|
185
|
+
reads the end of every process from the forkserver: the pool would break. A
|
|
186
|
+
SIGTERM to the worker meanwhile is delivered when the mask is restored.
|
|
187
|
+
"""
|
|
188
|
+
previous = signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGTERM})
|
|
189
|
+
try:
|
|
190
|
+
forkserver.ensure_running()
|
|
191
|
+
finally:
|
|
192
|
+
signal.pthread_sigmask(signal.SIG_SETMASK, previous)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _kill(pids: set[int]) -> None:
|
|
196
|
+
"""SIGKILL the processes of a pool, without the lock of the executor.
|
|
197
|
+
|
|
198
|
+
When a process dies, the manager thread of the executor holds its lock while it
|
|
199
|
+
sends SIGTERM to the others, which ignore it, and joins them; so
|
|
200
|
+
`ProcessPoolExecutor.kill_workers`, which takes that lock, would wait forever.
|
|
201
|
+
"""
|
|
202
|
+
for pid in pids:
|
|
203
|
+
with contextlib.suppress(ProcessLookupError):
|
|
204
|
+
os.kill(pid, signal.SIGKILL)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _compute(
|
|
208
|
+
setup: str | None, ref: str, settings: BaseModel, index: int, paths: TaskPaths
|
|
209
|
+
) -> float:
|
|
210
|
+
"""Compute one unit in a process of the pool, after the setup of the process.
|
|
211
|
+
|
|
212
|
+
The setup runs in the first unit of a process, so its exception is the exception
|
|
213
|
+
of that unit, and a broken pool only means that a process died.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
The seconds the unit took in the process, its setup included.
|
|
217
|
+
"""
|
|
218
|
+
start = time.monotonic()
|
|
219
|
+
if setup is not None and setup not in _SETUPS_DONE:
|
|
220
|
+
resolve(setup)(settings, paths)
|
|
221
|
+
_SETUPS_DONE.add(setup)
|
|
222
|
+
resolve(ref)(settings, index, paths)
|
|
223
|
+
return time.monotonic() - start
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@contextmanager
|
|
227
|
+
def _stop_on_sigterm(stop: threading.Event) -> Generator[None]:
|
|
228
|
+
"""Set `stop` on SIGTERM, in the main thread of this process only."""
|
|
229
|
+
if threading.current_thread() is not threading.main_thread():
|
|
230
|
+
logger.debug("not in the main thread: SIGTERM is not handled")
|
|
231
|
+
yield
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
def handler(signum: int, frame: object) -> None:
|
|
235
|
+
stop.set()
|
|
236
|
+
|
|
237
|
+
previous = signal.signal(signal.SIGTERM, handler)
|
|
238
|
+
try:
|
|
239
|
+
yield
|
|
240
|
+
finally:
|
|
241
|
+
signal.signal(signal.SIGTERM, previous)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _append_progress(path: Path, worker: str, index: int) -> None:
|
|
245
|
+
"""Append a `unit_done` line, with one write of a file opened with O_APPEND."""
|
|
246
|
+
line = json.dumps(
|
|
247
|
+
{
|
|
248
|
+
"time": datetime.now(UTC).isoformat(),
|
|
249
|
+
"worker": worker,
|
|
250
|
+
"event": "unit_done",
|
|
251
|
+
"unit": index,
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o644)
|
|
255
|
+
try:
|
|
256
|
+
os.write(fd, f"{line}\n".encode())
|
|
257
|
+
finally:
|
|
258
|
+
os.close(fd)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def write_report(report: WorkerReport, task_dir: Path, attempt: int = 0) -> Path:
|
|
262
|
+
"""Write the report as `task_dir/workers/<worker>-<attempt>.json`.
|
|
263
|
+
|
|
264
|
+
The file is written as a temporary file and renamed.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
report: The report.
|
|
268
|
+
task_dir: The task directory.
|
|
269
|
+
attempt: The attempt of the pod.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
The path of the report.
|
|
273
|
+
"""
|
|
274
|
+
directory = task_dir / WORKERS_DIR
|
|
275
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
276
|
+
name = f"{report.worker}-{attempt}.json"
|
|
277
|
+
path = directory / name
|
|
278
|
+
tmp = directory / f".{name}.{uuid.uuid4().hex}.tmp"
|
|
279
|
+
tmp.write_text(json.dumps(asdict(report), indent=2))
|
|
280
|
+
os.replace(tmp, path)
|
|
281
|
+
return path
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@dataclass
|
|
285
|
+
class _Units:
|
|
286
|
+
"""The state of the units of one worker."""
|
|
287
|
+
|
|
288
|
+
ledger: Ledger
|
|
289
|
+
worker: str
|
|
290
|
+
progress: Path
|
|
291
|
+
done: list[int] = field(default_factory=list)
|
|
292
|
+
failed: int | None = None
|
|
293
|
+
broken: BrokenProcessPool | None = None
|
|
294
|
+
seconds: float = 0.0
|
|
295
|
+
|
|
296
|
+
def complete(self, index: int, seconds: float) -> None:
|
|
297
|
+
try:
|
|
298
|
+
self.ledger.complete(index, self.worker)
|
|
299
|
+
except ValueError:
|
|
300
|
+
logger.warning(
|
|
301
|
+
"unit %d was computed, but its claim was lost to a restarted "
|
|
302
|
+
"incarnation of %s",
|
|
303
|
+
index,
|
|
304
|
+
self.worker,
|
|
305
|
+
)
|
|
306
|
+
return
|
|
307
|
+
self.done.append(index)
|
|
308
|
+
self.seconds += seconds
|
|
309
|
+
_append_progress(self.progress, self.worker, index)
|
|
310
|
+
|
|
311
|
+
def release(self, index: int) -> None:
|
|
312
|
+
try:
|
|
313
|
+
self.ledger.release(index, self.worker)
|
|
314
|
+
except ValueError:
|
|
315
|
+
logger.warning(
|
|
316
|
+
"unit %d: its claim was lost to a restarted incarnation of %s",
|
|
317
|
+
index,
|
|
318
|
+
self.worker,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
def settle(self, future: Future[float], index: int) -> None:
|
|
322
|
+
"""Complete the unit of a future which succeeded, else release it."""
|
|
323
|
+
if future.done() and not future.cancelled() and future.exception() is None:
|
|
324
|
+
self.complete(index, future.result())
|
|
325
|
+
else:
|
|
326
|
+
self.release(index)
|
|
327
|
+
|
|
328
|
+
def finished(self, future: Future[float], index: int) -> None:
|
|
329
|
+
"""Complete the unit of a finished future, or release it and note why."""
|
|
330
|
+
error = None if future.cancelled() else future.exception()
|
|
331
|
+
if not future.cancelled() and error is None:
|
|
332
|
+
self.complete(index, future.result())
|
|
333
|
+
return
|
|
334
|
+
self.release(index)
|
|
335
|
+
if isinstance(error, BrokenProcessPool):
|
|
336
|
+
if self.broken is None:
|
|
337
|
+
logger.error("a process of the pool died: %s", error)
|
|
338
|
+
self.broken = error
|
|
339
|
+
elif error is not None:
|
|
340
|
+
logger.error("unit %d failed", index, exc_info=error)
|
|
341
|
+
if self.failed is None:
|
|
342
|
+
self.failed = index
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@dataclass(frozen=True)
|
|
346
|
+
class _Coordination:
|
|
347
|
+
"""How the coordinator waits for the units of other workers.
|
|
348
|
+
|
|
349
|
+
Attributes:
|
|
350
|
+
stale_s: Seconds after which the heartbeat of a worker is stale.
|
|
351
|
+
poll_s: Seconds between two releases of stale claims.
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
stale_s: float
|
|
355
|
+
poll_s: float
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class _OomKills:
|
|
359
|
+
"""The kills of the out of memory killer in the cgroup from now on."""
|
|
360
|
+
|
|
361
|
+
def __init__(self) -> None:
|
|
362
|
+
"""Read the counter of `memory.events` now."""
|
|
363
|
+
self.start = cgroup.memory_oom_kills()
|
|
364
|
+
|
|
365
|
+
def since(self) -> int:
|
|
366
|
+
"""The kills since the object was created, 0 without the counter."""
|
|
367
|
+
now = cgroup.memory_oom_kills()
|
|
368
|
+
if now is None:
|
|
369
|
+
return 0
|
|
370
|
+
return now - (self.start or 0)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class MemorySampler:
|
|
374
|
+
"""The maximum of the anonymous memory of the cgroup over its samples.
|
|
375
|
+
|
|
376
|
+
The anonymous memory (`memory.stat` `anon`) is what the processes allocated;
|
|
377
|
+
`memory.peak` also counts the page cache of staging, installing and writing
|
|
378
|
+
outputs, which the kernel reclaims at the limit instead of killing.
|
|
379
|
+
|
|
380
|
+
Attributes:
|
|
381
|
+
peak: The largest sample in bytes, None before one was taken.
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
def __init__(self) -> None:
|
|
385
|
+
"""Create a sampler without samples."""
|
|
386
|
+
self.peak: int | None = None
|
|
387
|
+
|
|
388
|
+
def sample(self) -> None:
|
|
389
|
+
"""Read the anonymous memory and keep it when it is the largest."""
|
|
390
|
+
value = cgroup.memory_anon()
|
|
391
|
+
if value is not None and (self.peak is None or value > self.peak):
|
|
392
|
+
self.peak = value
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@contextmanager
|
|
396
|
+
def _heartbeats(
|
|
397
|
+
ledger: Ledger | None, worker: str, heartbeat_s: float, sampler: MemorySampler
|
|
398
|
+
) -> Generator[None]:
|
|
399
|
+
"""Heartbeat and sample the memory in a thread until the end.
|
|
400
|
+
|
|
401
|
+
The heartbeat of `worker` is touched now and every `heartbeat_s` (not without a
|
|
402
|
+
ledger), the memory is sampled now, every `SAMPLE_S` and at the end.
|
|
403
|
+
"""
|
|
404
|
+
if ledger is not None:
|
|
405
|
+
ledger.heartbeat(worker)
|
|
406
|
+
sampler.sample()
|
|
407
|
+
done = threading.Event()
|
|
408
|
+
|
|
409
|
+
def beat() -> None:
|
|
410
|
+
next_beat = time.monotonic() + heartbeat_s
|
|
411
|
+
while True:
|
|
412
|
+
timeout = SAMPLE_S
|
|
413
|
+
if ledger is not None:
|
|
414
|
+
timeout = max(0.0, min(timeout, next_beat - time.monotonic()))
|
|
415
|
+
if done.wait(timeout):
|
|
416
|
+
return
|
|
417
|
+
sampler.sample()
|
|
418
|
+
if ledger is None or time.monotonic() < next_beat:
|
|
419
|
+
continue
|
|
420
|
+
next_beat = time.monotonic() + heartbeat_s
|
|
421
|
+
try:
|
|
422
|
+
ledger.heartbeat(worker)
|
|
423
|
+
except OSError as error:
|
|
424
|
+
logger.warning("heartbeat of %s failed: %s", worker, error)
|
|
425
|
+
|
|
426
|
+
thread = threading.Thread(target=beat, name=f"heartbeat-{worker}", daemon=True)
|
|
427
|
+
thread.start()
|
|
428
|
+
try:
|
|
429
|
+
yield
|
|
430
|
+
finally:
|
|
431
|
+
done.set()
|
|
432
|
+
thread.join()
|
|
433
|
+
sampler.sample()
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _compute_units(
|
|
437
|
+
task_type: TaskType,
|
|
438
|
+
settings: BaseModel,
|
|
439
|
+
paths: TaskPaths,
|
|
440
|
+
units: _Units,
|
|
441
|
+
n_processes: int,
|
|
442
|
+
stop: threading.Event,
|
|
443
|
+
grace_s: float,
|
|
444
|
+
coordination: _Coordination | None,
|
|
445
|
+
) -> None:
|
|
446
|
+
"""Keep `n_processes` units in flight until none is pending or the worker stops.
|
|
447
|
+
|
|
448
|
+
A coordinator (`coordination` given) continues until the ledger is complete: it
|
|
449
|
+
releases the claims of workers with a stale heartbeat every `poll_s` and computes
|
|
450
|
+
them.
|
|
451
|
+
"""
|
|
452
|
+
assert task_type.compute_unit is not None
|
|
453
|
+
_start_forkserver()
|
|
454
|
+
pool = ProcessPoolExecutor(
|
|
455
|
+
max_workers=n_processes,
|
|
456
|
+
mp_context=multiprocessing.get_context("forkserver"),
|
|
457
|
+
initializer=_init_process,
|
|
458
|
+
)
|
|
459
|
+
in_flight: dict[Future[float], int] = {}
|
|
460
|
+
# the processes the pool started, which it starts in `submit` only
|
|
461
|
+
started: set[int] = set()
|
|
462
|
+
deadline: float | None = None
|
|
463
|
+
next_release = 0.0
|
|
464
|
+
try:
|
|
465
|
+
while True:
|
|
466
|
+
if stop.is_set() and deadline is None:
|
|
467
|
+
deadline = time.monotonic() + grace_s
|
|
468
|
+
logger.info(
|
|
469
|
+
"SIGTERM: %d units running, waiting up to %s s",
|
|
470
|
+
len(in_flight),
|
|
471
|
+
grace_s,
|
|
472
|
+
)
|
|
473
|
+
claiming = not stop.is_set() and units.failed is None
|
|
474
|
+
if (
|
|
475
|
+
coordination is not None
|
|
476
|
+
and claiming
|
|
477
|
+
and units.broken is None
|
|
478
|
+
and time.monotonic() >= next_release
|
|
479
|
+
):
|
|
480
|
+
units.ledger.release_stale(coordination.stale_s, units.worker)
|
|
481
|
+
next_release = time.monotonic() + coordination.poll_s
|
|
482
|
+
while claiming and units.broken is None and len(in_flight) < n_processes:
|
|
483
|
+
index = units.ledger.claim(units.worker)
|
|
484
|
+
if index is None:
|
|
485
|
+
break
|
|
486
|
+
try:
|
|
487
|
+
future = pool.submit(
|
|
488
|
+
_compute,
|
|
489
|
+
task_type.setup,
|
|
490
|
+
task_type.compute_unit,
|
|
491
|
+
settings,
|
|
492
|
+
index,
|
|
493
|
+
paths,
|
|
494
|
+
)
|
|
495
|
+
except BrokenProcessPool as error:
|
|
496
|
+
units.broken = error
|
|
497
|
+
units.release(index)
|
|
498
|
+
break
|
|
499
|
+
in_flight[future] = index
|
|
500
|
+
started.update(pool._processes or ())
|
|
501
|
+
if units.failed is not None or units.broken is not None:
|
|
502
|
+
break
|
|
503
|
+
if not in_flight:
|
|
504
|
+
if (
|
|
505
|
+
coordination is None
|
|
506
|
+
or stop.is_set()
|
|
507
|
+
or units.ledger.complete_ledger()
|
|
508
|
+
):
|
|
509
|
+
break
|
|
510
|
+
# other workers hold the rest: wait for them, or for their heartbeat
|
|
511
|
+
# to go stale
|
|
512
|
+
stop.wait(max(0.0, next_release - time.monotonic()))
|
|
513
|
+
continue
|
|
514
|
+
timeout = POLL_S
|
|
515
|
+
if deadline is not None:
|
|
516
|
+
timeout = min(timeout, deadline - time.monotonic())
|
|
517
|
+
if timeout <= 0:
|
|
518
|
+
break
|
|
519
|
+
finished, _ = wait(in_flight, timeout=timeout, return_when=FIRST_COMPLETED)
|
|
520
|
+
for future in finished:
|
|
521
|
+
units.finished(future, in_flight.pop(future))
|
|
522
|
+
finally:
|
|
523
|
+
if in_flight or units.broken is not None:
|
|
524
|
+
# end the processes before their units are freed, and every process of
|
|
525
|
+
# a broken pool, whose manager thread waits for them
|
|
526
|
+
_kill(started)
|
|
527
|
+
pool.shutdown(wait=True, cancel_futures=True)
|
|
528
|
+
for future, index in in_flight.items():
|
|
529
|
+
units.settle(future, index)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def run_worker(
|
|
533
|
+
task_type: TaskType,
|
|
534
|
+
settings: BaseModel,
|
|
535
|
+
paths: TaskPaths,
|
|
536
|
+
worker: str,
|
|
537
|
+
*,
|
|
538
|
+
cores: int,
|
|
539
|
+
memory_bytes: int | None,
|
|
540
|
+
base_gb: float,
|
|
541
|
+
per_worker_gb: float,
|
|
542
|
+
grace_s: float = GRACE_S,
|
|
543
|
+
attempt: int = 0,
|
|
544
|
+
stop: threading.Event | None = None,
|
|
545
|
+
heartbeat_s: float = HEARTBEAT_S,
|
|
546
|
+
coordinator: bool = False,
|
|
547
|
+
stale_s: float | None = None,
|
|
548
|
+
poll_s: float = COORDINATOR_POLL_S,
|
|
549
|
+
) -> WorkerReport:
|
|
550
|
+
"""Compute units of the ledger of a task until none is pending.
|
|
551
|
+
|
|
552
|
+
The worker touches its heartbeat in the ledger every `heartbeat_s` from its start
|
|
553
|
+
to its end. A coordinator does not return when nothing is pending: until the
|
|
554
|
+
ledger is complete it waits, releases the claims of workers whose heartbeat is
|
|
555
|
+
older than `stale_s` every `poll_s` and computes them.
|
|
556
|
+
|
|
557
|
+
Releases the claims of an earlier incarnation of `worker`, creates the ledger
|
|
558
|
+
`task_dir/units`, keeps `processes(...)` units in flight in a pool of processes
|
|
559
|
+
which run the setup of the task type once, completes a unit after its function
|
|
560
|
+
returned and appends a `unit_done` line to `task_dir/progress.jsonl`. On SIGTERM
|
|
561
|
+
(or when `stop` is set, also before the first claim) it stops claiming, waits for
|
|
562
|
+
the running units up to `grace_s` and releases the rest. The report
|
|
563
|
+
`task_dir/workers/<worker>-<attempt>.json` is written in every case.
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
task_type: The task type, splittable.
|
|
567
|
+
settings: The settings of the task.
|
|
568
|
+
paths: The locations of the task.
|
|
569
|
+
worker: The worker id, the same for every incarnation of a pod.
|
|
570
|
+
cores: The number of cores.
|
|
571
|
+
memory_bytes: The memory limit in bytes, `None` without a limit.
|
|
572
|
+
base_gb: The memory of the task besides its processes in GiB.
|
|
573
|
+
per_worker_gb: The memory per process in GiB.
|
|
574
|
+
grace_s: Seconds the running units get after SIGTERM.
|
|
575
|
+
attempt: The attempt of the pod, which names its report.
|
|
576
|
+
stop: Set on SIGTERM by a caller which handles it already, e.g. `main`.
|
|
577
|
+
heartbeat_s: Seconds between two heartbeats.
|
|
578
|
+
coordinator: Whether the worker continues until the ledger is complete.
|
|
579
|
+
stale_s: Seconds after which the heartbeat of another worker is stale,
|
|
580
|
+
`grace_s + 60` by default.
|
|
581
|
+
poll_s: Seconds between two releases of stale claims by a coordinator.
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
The report.
|
|
585
|
+
|
|
586
|
+
Raises:
|
|
587
|
+
ValueError: The task type is not splittable, the memory is too small for one
|
|
588
|
+
process, the worker id is invalid or the ledger has another size.
|
|
589
|
+
UnitError: A unit failed; it was released.
|
|
590
|
+
BrokenProcessPool: A process of the pool died, e.g. killed for its memory.
|
|
591
|
+
Terminated: The worker stopped on SIGTERM.
|
|
592
|
+
"""
|
|
593
|
+
start = time.monotonic()
|
|
594
|
+
if not task_type.splittable:
|
|
595
|
+
raise ValueError(f"task type {task_type.name!r} is not splittable")
|
|
596
|
+
assert task_type.count_units is not None
|
|
597
|
+
if stop is None:
|
|
598
|
+
stop = threading.Event()
|
|
599
|
+
with _stop_on_sigterm(stop):
|
|
600
|
+
n_units = resolve(task_type.count_units)(settings)
|
|
601
|
+
ledger = Ledger.create(paths.task_dir / UNITS_DIR, n_units)
|
|
602
|
+
released = ledger.release_all(worker)
|
|
603
|
+
if released:
|
|
604
|
+
logger.info("released %d units of an earlier incarnation", len(released))
|
|
605
|
+
n_processes = processes(cores, memory_bytes, base_gb, per_worker_gb)
|
|
606
|
+
logger.info("worker %s: %d processes, %d units", worker, n_processes, n_units)
|
|
607
|
+
units = _Units(ledger, worker, paths.task_dir / PROGRESS_FILE)
|
|
608
|
+
sampler = MemorySampler()
|
|
609
|
+
oom_kills = _OomKills()
|
|
610
|
+
coordination = None
|
|
611
|
+
if coordinator:
|
|
612
|
+
stale = grace_s + STALE_MARGIN_S if stale_s is None else stale_s
|
|
613
|
+
coordination = _Coordination(stale_s=stale, poll_s=poll_s)
|
|
614
|
+
try:
|
|
615
|
+
with _heartbeats(ledger, worker, heartbeat_s, sampler):
|
|
616
|
+
_compute_units(
|
|
617
|
+
task_type,
|
|
618
|
+
settings,
|
|
619
|
+
paths,
|
|
620
|
+
units,
|
|
621
|
+
n_processes,
|
|
622
|
+
stop,
|
|
623
|
+
grace_s,
|
|
624
|
+
coordination,
|
|
625
|
+
)
|
|
626
|
+
finally:
|
|
627
|
+
report = WorkerReport(
|
|
628
|
+
worker=worker,
|
|
629
|
+
node=os.environ.get("SIMRUN_NODE") or socket.gethostname(),
|
|
630
|
+
processes=n_processes,
|
|
631
|
+
units_done=sorted(units.done),
|
|
632
|
+
seconds=time.monotonic() - start,
|
|
633
|
+
peak_memory_bytes=sampler.peak,
|
|
634
|
+
memory_limit_bytes=cgroup.memory_limit(),
|
|
635
|
+
per_worker_gb=per_worker_gb,
|
|
636
|
+
oom_kills=oom_kills.since(),
|
|
637
|
+
unit_seconds=units.seconds,
|
|
638
|
+
)
|
|
639
|
+
write_report(report, paths.task_dir, attempt)
|
|
640
|
+
if units.failed is not None:
|
|
641
|
+
raise UnitError(units.failed)
|
|
642
|
+
if stop.is_set():
|
|
643
|
+
# also when the pool broke after SIGTERM: an eviction, not a lack of memory
|
|
644
|
+
raise Terminated(f"worker {worker} stopped on SIGTERM")
|
|
645
|
+
if units.broken is not None:
|
|
646
|
+
raise units.broken
|
|
647
|
+
return report
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _missing_text(missing: list[int], limit: int = 20) -> str:
|
|
651
|
+
text = ", ".join(str(i) for i in missing[:limit])
|
|
652
|
+
if len(missing) > limit:
|
|
653
|
+
text += f", ... ({len(missing)} in all)"
|
|
654
|
+
return text
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def run_merge(task_type: TaskType, settings: BaseModel, paths: TaskPaths) -> None:
|
|
658
|
+
"""Merge the units of a task once its ledger is complete.
|
|
659
|
+
|
|
660
|
+
Args:
|
|
661
|
+
task_type: The task type, splittable.
|
|
662
|
+
settings: The settings of the task.
|
|
663
|
+
paths: The locations of the task.
|
|
664
|
+
|
|
665
|
+
Raises:
|
|
666
|
+
ValueError: The task type is not splittable.
|
|
667
|
+
RuntimeError: Units are not done; the message names them.
|
|
668
|
+
"""
|
|
669
|
+
if task_type.count_units is None or task_type.merge is None:
|
|
670
|
+
raise ValueError(f"task type {task_type.name!r} is not splittable")
|
|
671
|
+
n_units = resolve(task_type.count_units)(settings)
|
|
672
|
+
root = paths.task_dir / UNITS_DIR
|
|
673
|
+
missing = Ledger(root, n_units).missing() if root.exists() else list(range(n_units))
|
|
674
|
+
if missing:
|
|
675
|
+
raise RuntimeError(
|
|
676
|
+
f"the ledger of {paths.task_dir} is incomplete, missing units: "
|
|
677
|
+
f"{_missing_text(missing)}"
|
|
678
|
+
)
|
|
679
|
+
resolve(task_type.merge)(settings, paths)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _pairs(value: str) -> list[tuple[str, str]]:
|
|
683
|
+
"""Parse comma separated `a=b` pairs."""
|
|
684
|
+
pairs = []
|
|
685
|
+
for item in filter(None, (part.strip() for part in value.split(","))):
|
|
686
|
+
key, sep, target = item.partition("=")
|
|
687
|
+
if not sep or not key or not target:
|
|
688
|
+
raise ConfigurationError(f"not a pair a=b: {item!r}")
|
|
689
|
+
pairs.append((key, target))
|
|
690
|
+
return pairs
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def paths_from_env(env: Mapping[str, str]) -> TaskPaths:
|
|
694
|
+
"""The locations of a task from the environment of its pod.
|
|
695
|
+
|
|
696
|
+
The task directory is `<store>/runs/<run>/tasks/<task>` (`SIMRUN_TASK_DIR`), the
|
|
697
|
+
inputs are `port=path` pairs (`SIMRUN_INPUTS`, a relative path is relative to the
|
|
698
|
+
store) and the scratch directory is `SIMRUN_SCRATCH` (default `/scratch`).
|
|
699
|
+
|
|
700
|
+
Args:
|
|
701
|
+
env: The environment.
|
|
702
|
+
|
|
703
|
+
Returns:
|
|
704
|
+
The locations.
|
|
705
|
+
|
|
706
|
+
Raises:
|
|
707
|
+
ConfigurationError: `SIMRUN_TASK_DIR` is not set or a pair is malformed.
|
|
708
|
+
"""
|
|
709
|
+
if not env.get("SIMRUN_TASK_DIR"):
|
|
710
|
+
raise ConfigurationError("SIMRUN_TASK_DIR is not set")
|
|
711
|
+
task_dir = Path(env["SIMRUN_TASK_DIR"])
|
|
712
|
+
run_dir = task_dir.parent.parent
|
|
713
|
+
store = run_dir.parent.parent
|
|
714
|
+
inputs = {port: store / path for port, path in _pairs(env.get("SIMRUN_INPUTS", ""))}
|
|
715
|
+
return TaskPaths(
|
|
716
|
+
store=store,
|
|
717
|
+
run_dir=run_dir,
|
|
718
|
+
task_dir=task_dir,
|
|
719
|
+
out=task_dir / "out",
|
|
720
|
+
inputs=inputs,
|
|
721
|
+
scratch=Path(env.get("SIMRUN_SCRATCH") or DEFAULT_SCRATCH),
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def shadow_tree(source: Path, target: Path) -> Path:
|
|
726
|
+
"""A writable tree of a read-only one: its directories, links to its files.
|
|
727
|
+
|
|
728
|
+
A build backend may write into the tree it builds (a version file, metadata, a
|
|
729
|
+
build directory); it writes into the writable directories of the shadow, and
|
|
730
|
+
never into the source. A link (to a file or a directory) of the source is a link
|
|
731
|
+
to it in the shadow.
|
|
732
|
+
|
|
733
|
+
Args:
|
|
734
|
+
source: The read-only tree.
|
|
735
|
+
target: The shadow to create, replaced when it exists.
|
|
736
|
+
|
|
737
|
+
Returns:
|
|
738
|
+
`target`.
|
|
739
|
+
"""
|
|
740
|
+
if target.exists():
|
|
741
|
+
shutil.rmtree(target)
|
|
742
|
+
for dirpath, dirnames, filenames in os.walk(source):
|
|
743
|
+
directory = Path(dirpath)
|
|
744
|
+
shadow = target / directory.relative_to(source)
|
|
745
|
+
shadow.mkdir(parents=True, exist_ok=True)
|
|
746
|
+
for name in list(dirnames):
|
|
747
|
+
if (directory / name).is_symlink():
|
|
748
|
+
dirnames.remove(name)
|
|
749
|
+
filenames.append(name)
|
|
750
|
+
for name in filenames:
|
|
751
|
+
(shadow / name).symlink_to(directory / name)
|
|
752
|
+
return target
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def install_editable(
|
|
756
|
+
snapshots: list[str], python: str = sys.executable, root: Path | None = None
|
|
757
|
+
) -> None:
|
|
758
|
+
"""Install snapshots of sources as editable packages, without dependencies.
|
|
759
|
+
|
|
760
|
+
The image has the dependencies and the build backends of the sources (see
|
|
761
|
+
`simrun.environment`), so the snapshots are built without isolation and without
|
|
762
|
+
an index. A snapshot is mounted read-only: each is installed from a writable
|
|
763
|
+
shadow tree (`shadow_tree`) below `root`, whose files are links to the snapshot.
|
|
764
|
+
|
|
765
|
+
Args:
|
|
766
|
+
snapshots: The paths of the snapshots; nothing is installed without one.
|
|
767
|
+
python: The interpreter of the environment to install into.
|
|
768
|
+
root: The directory of the shadow trees, `SOURCES_DIR` below the temporary
|
|
769
|
+
directory by default.
|
|
770
|
+
|
|
771
|
+
Raises:
|
|
772
|
+
subprocess.CalledProcessError: The installation failed.
|
|
773
|
+
OSError: A shadow tree cannot be created.
|
|
774
|
+
"""
|
|
775
|
+
if not snapshots:
|
|
776
|
+
return
|
|
777
|
+
if root is None:
|
|
778
|
+
root = Path(tempfile.gettempdir()) / SOURCES_DIR
|
|
779
|
+
command = [
|
|
780
|
+
"uv",
|
|
781
|
+
"pip",
|
|
782
|
+
"install",
|
|
783
|
+
"--python",
|
|
784
|
+
python,
|
|
785
|
+
"--no-deps",
|
|
786
|
+
"--no-index",
|
|
787
|
+
"--no-build-isolation",
|
|
788
|
+
]
|
|
789
|
+
for index, snapshot in enumerate(snapshots):
|
|
790
|
+
source = Path(snapshot)
|
|
791
|
+
shadow = shadow_tree(source, root / f"{index}-{source.name}")
|
|
792
|
+
command += ["-e", str(shadow)]
|
|
793
|
+
logger.info("installing %s", " ".join(snapshots))
|
|
794
|
+
subprocess.run(command, check=True)
|
|
795
|
+
# the path files of the packages are read at the start of an interpreter only
|
|
796
|
+
for directory in site.getsitepackages():
|
|
797
|
+
site.addsitedir(directory)
|
|
798
|
+
importlib.invalidate_caches()
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def stage(pairs: list[tuple[str, str]], store: Path, scratch: Path) -> None:
|
|
802
|
+
"""Copy inputs to the scratch directory.
|
|
803
|
+
|
|
804
|
+
Args:
|
|
805
|
+
pairs: `(source, destination)`; a relative source is relative to the store, a
|
|
806
|
+
relative destination to the scratch directory.
|
|
807
|
+
store: The root of the run store.
|
|
808
|
+
scratch: The scratch directory.
|
|
809
|
+
"""
|
|
810
|
+
for source, destination in pairs:
|
|
811
|
+
src = store / source
|
|
812
|
+
dst = scratch / destination
|
|
813
|
+
logger.info("staging %s to %s", src, dst)
|
|
814
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
815
|
+
if src.is_dir():
|
|
816
|
+
shutil.copytree(src, dst, dirs_exist_ok=True)
|
|
817
|
+
else:
|
|
818
|
+
shutil.copy2(src, dst)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _task(env: Mapping[str, str]) -> tuple[TaskType, BaseModel, TaskPaths]:
|
|
822
|
+
"""The task type, the settings and the paths of the task of the environment."""
|
|
823
|
+
paths = paths_from_env(env)
|
|
824
|
+
name = env.get("SIMRUN_TASK_TYPE")
|
|
825
|
+
if not name:
|
|
826
|
+
raise ConfigurationError("SIMRUN_TASK_TYPE is not set")
|
|
827
|
+
types = load_task_types()
|
|
828
|
+
if name not in types:
|
|
829
|
+
raise ConfigurationError(f"unknown task type {name!r}")
|
|
830
|
+
task_type = types[name]
|
|
831
|
+
settings = task_type.settings.model_validate_json(
|
|
832
|
+
(paths.task_dir / SETTINGS_FILE).read_text()
|
|
833
|
+
)
|
|
834
|
+
return task_type, settings, paths
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _attempt(env: Mapping[str, str]) -> int:
|
|
838
|
+
value = env.get("SIMRUN_ATTEMPT") or "0"
|
|
839
|
+
try:
|
|
840
|
+
return int(value)
|
|
841
|
+
except ValueError:
|
|
842
|
+
raise ConfigurationError(
|
|
843
|
+
f"SIMRUN_ATTEMPT is not an integer: {value!r}"
|
|
844
|
+
) from None
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _coordinator(env: Mapping[str, str]) -> bool:
|
|
848
|
+
role = env.get("SIMRUN_ROLE") or "extra"
|
|
849
|
+
if role not in ROLES:
|
|
850
|
+
raise ConfigurationError(f"SIMRUN_ROLE is not one of {sorted(ROLES)}: {role!r}")
|
|
851
|
+
return ROLES[role]
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _stale_s(env: Mapping[str, str]) -> float | None:
|
|
855
|
+
value = env.get("SIMRUN_STALE_S")
|
|
856
|
+
if not value:
|
|
857
|
+
return None
|
|
858
|
+
try:
|
|
859
|
+
return float(value)
|
|
860
|
+
except ValueError:
|
|
861
|
+
raise ConfigurationError(f"SIMRUN_STALE_S is not a number: {value!r}") from None
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _editable(env: Mapping[str, str]) -> list[str]:
|
|
865
|
+
value = env.get("SIMRUN_EDITABLE", "")
|
|
866
|
+
return [path.strip() for path in value.split(",") if path.strip()]
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _gb(env: Mapping[str, str], name: str, default: float) -> float:
|
|
870
|
+
value = env.get(name)
|
|
871
|
+
if not value:
|
|
872
|
+
return default
|
|
873
|
+
try:
|
|
874
|
+
return float(value)
|
|
875
|
+
except ValueError:
|
|
876
|
+
raise ConfigurationError(f"{name} is not a number: {value!r}") from None
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
CONFIGURATION_ERRORS = (
|
|
880
|
+
ConfigurationError,
|
|
881
|
+
ValueError,
|
|
882
|
+
OSError,
|
|
883
|
+
subprocess.CalledProcessError,
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _logging() -> None:
|
|
888
|
+
logging.basicConfig(
|
|
889
|
+
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def main() -> None:
|
|
894
|
+
"""Run `simrun-worker` in a pod of a split task; exits with its code.
|
|
895
|
+
|
|
896
|
+
SIGTERM is handled from the start, so a pod evicted while it prepares (as PID 1
|
|
897
|
+
without a handler it would ignore SIGTERM) stops before its first claim. The
|
|
898
|
+
worker of `SIMRUN_ROLE=base` is the coordinator, which returns once the ledger is
|
|
899
|
+
complete (stale claims released after `SIMRUN_STALE_S`); `extra` (the default)
|
|
900
|
+
returns when nothing is pending.
|
|
901
|
+
|
|
902
|
+
Exit codes: 0 done, 3 a unit failed (logged and released), 4 a configuration
|
|
903
|
+
error, 137 a process of the pool died (retried with more memory), 143 stopped on
|
|
904
|
+
SIGTERM (retried).
|
|
905
|
+
"""
|
|
906
|
+
stop = threading.Event()
|
|
907
|
+
with _stop_on_sigterm(stop):
|
|
908
|
+
sys.exit(_worker(stop))
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _worker(stop: threading.Event) -> int:
|
|
912
|
+
"""Prepare and run the worker of `main`; returns its exit code."""
|
|
913
|
+
_logging()
|
|
914
|
+
env = os.environ
|
|
915
|
+
try:
|
|
916
|
+
install_editable(_editable(env))
|
|
917
|
+
task_type, settings, paths = _task(env)
|
|
918
|
+
worker = env.get("SIMRUN_WORKER")
|
|
919
|
+
if not worker:
|
|
920
|
+
raise ConfigurationError("SIMRUN_WORKER is not set")
|
|
921
|
+
attempt = _attempt(env)
|
|
922
|
+
coordinator = _coordinator(env)
|
|
923
|
+
stale_s = _stale_s(env)
|
|
924
|
+
stage(_pairs(env.get("SIMRUN_STAGE", "")), paths.store, paths.scratch)
|
|
925
|
+
base_gb = _gb(env, "SIMRUN_MEMORY_BASE_GB", task_type.hint.memory_base_gb)
|
|
926
|
+
per_worker_gb = _gb(
|
|
927
|
+
env, "SIMRUN_MEMORY_PER_WORKER_GB", task_type.hint.memory_per_worker_gb
|
|
928
|
+
)
|
|
929
|
+
except CONFIGURATION_ERRORS as error:
|
|
930
|
+
logger.error("configuration error: %s", error)
|
|
931
|
+
return EXIT_CONFIGURATION
|
|
932
|
+
if stop.is_set():
|
|
933
|
+
logger.info("stopped on SIGTERM before the first claim")
|
|
934
|
+
return EXIT_TERMINATED
|
|
935
|
+
for name in THREAD_VARIABLES:
|
|
936
|
+
os.environ[name] = "1"
|
|
937
|
+
try:
|
|
938
|
+
run_worker(
|
|
939
|
+
task_type,
|
|
940
|
+
settings,
|
|
941
|
+
paths,
|
|
942
|
+
worker,
|
|
943
|
+
cores=cgroup.cpu_quota() or os.process_cpu_count() or 1,
|
|
944
|
+
memory_bytes=cgroup.memory_limit(),
|
|
945
|
+
base_gb=base_gb,
|
|
946
|
+
per_worker_gb=per_worker_gb,
|
|
947
|
+
attempt=attempt,
|
|
948
|
+
stop=stop,
|
|
949
|
+
coordinator=coordinator,
|
|
950
|
+
stale_s=stale_s,
|
|
951
|
+
)
|
|
952
|
+
except UnitError:
|
|
953
|
+
return EXIT_ANALYSIS
|
|
954
|
+
except BrokenProcessPool:
|
|
955
|
+
return EXIT_POOL_BROKEN
|
|
956
|
+
except Terminated as error:
|
|
957
|
+
logger.info("%s", error)
|
|
958
|
+
return EXIT_TERMINATED
|
|
959
|
+
except ValueError as error:
|
|
960
|
+
logger.error("configuration error: %s", error)
|
|
961
|
+
return EXIT_CONFIGURATION
|
|
962
|
+
return EXIT_DONE
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def merge_main() -> None:
|
|
966
|
+
"""Run `simrun-merge` after the workers of a split task; exits with its code.
|
|
967
|
+
|
|
968
|
+
SIGTERM is handled from the start: a merge stopped before it began exits 143.
|
|
969
|
+
A successful merge writes `out/.simrun-merged` (`MERGED_MARKER`).
|
|
970
|
+
|
|
971
|
+
Exit codes: 0 merged, 3 the ledger is incomplete or the merge failed, 4 a
|
|
972
|
+
configuration error, 143 stopped on SIGTERM before the merge.
|
|
973
|
+
"""
|
|
974
|
+
stop = threading.Event()
|
|
975
|
+
with _stop_on_sigterm(stop):
|
|
976
|
+
sys.exit(_merge(stop))
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _merge(stop: threading.Event) -> int:
|
|
980
|
+
"""Prepare and run the merge of `merge_main`; returns its exit code."""
|
|
981
|
+
_logging()
|
|
982
|
+
env = os.environ
|
|
983
|
+
try:
|
|
984
|
+
install_editable(_editable(env))
|
|
985
|
+
task_type, settings, paths = _task(env)
|
|
986
|
+
except CONFIGURATION_ERRORS as error:
|
|
987
|
+
logger.error("configuration error: %s", error)
|
|
988
|
+
return EXIT_CONFIGURATION
|
|
989
|
+
if stop.is_set():
|
|
990
|
+
logger.info("stopped on SIGTERM before the merge")
|
|
991
|
+
return EXIT_TERMINATED
|
|
992
|
+
try:
|
|
993
|
+
run_merge(task_type, settings, paths)
|
|
994
|
+
paths.out.mkdir(parents=True, exist_ok=True)
|
|
995
|
+
(paths.out / MERGED_MARKER).touch()
|
|
996
|
+
except Exception:
|
|
997
|
+
logger.exception("the merge of %s failed", paths.task_dir)
|
|
998
|
+
return EXIT_ANALYSIS
|
|
999
|
+
return EXIT_DONE
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def exec_main() -> None:
|
|
1003
|
+
"""Run `simrun-exec <command...>`, the command of an unsplit plugin task.
|
|
1004
|
+
|
|
1005
|
+
The harness installs the snapshots (`SIMRUN_EDITABLE`), stages the inputs, sets
|
|
1006
|
+
the thread variables of BLAS and OpenMP to 1 and runs the command as its child,
|
|
1007
|
+
while its heartbeat thread samples the memory. A SIGTERM is passed on to the
|
|
1008
|
+
command, which is killed after `GRACE_S`. The report
|
|
1009
|
+
`task_dir/workers/<task>-<attempt>.json` (the worker is the task id, or
|
|
1010
|
+
`SIMRUN_WORKER`) is written in every case after the command started, so the
|
|
1011
|
+
controller learns the memory of the task.
|
|
1012
|
+
|
|
1013
|
+
Exit codes: 0 done, 3 the command failed, 4 a configuration error (also a
|
|
1014
|
+
command which cannot be started), 137 the command was killed (by the out of
|
|
1015
|
+
memory killer, retried with more memory) or exited 137, 143 stopped on SIGTERM
|
|
1016
|
+
(retried).
|
|
1017
|
+
"""
|
|
1018
|
+
stop = threading.Event()
|
|
1019
|
+
with _stop_on_sigterm(stop):
|
|
1020
|
+
sys.exit(_exec(sys.argv[1:], stop))
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def _exec(argv: list[str], stop: threading.Event) -> int:
|
|
1024
|
+
"""Prepare and run the command of `exec_main`; returns its exit code."""
|
|
1025
|
+
_logging()
|
|
1026
|
+
env = os.environ
|
|
1027
|
+
if argv[:1] == ["--"]:
|
|
1028
|
+
argv = argv[1:]
|
|
1029
|
+
try:
|
|
1030
|
+
if not argv:
|
|
1031
|
+
raise ConfigurationError("simrun-exec needs a command")
|
|
1032
|
+
install_editable(_editable(env))
|
|
1033
|
+
paths = paths_from_env(env)
|
|
1034
|
+
attempt = _attempt(env)
|
|
1035
|
+
stage(_pairs(env.get("SIMRUN_STAGE", "")), paths.store, paths.scratch)
|
|
1036
|
+
per_worker_gb = _gb(env, "SIMRUN_MEMORY_PER_WORKER_GB", 0.0)
|
|
1037
|
+
except CONFIGURATION_ERRORS as error:
|
|
1038
|
+
logger.error("configuration error: %s", error)
|
|
1039
|
+
return EXIT_CONFIGURATION
|
|
1040
|
+
if stop.is_set():
|
|
1041
|
+
logger.info("stopped on SIGTERM before the command")
|
|
1042
|
+
return EXIT_TERMINATED
|
|
1043
|
+
for name in THREAD_VARIABLES:
|
|
1044
|
+
os.environ[name] = "1"
|
|
1045
|
+
worker = env.get("SIMRUN_WORKER") or paths.task_dir.name
|
|
1046
|
+
start = time.monotonic()
|
|
1047
|
+
oom_kills = _OomKills()
|
|
1048
|
+
try:
|
|
1049
|
+
process = subprocess.Popen(argv)
|
|
1050
|
+
except OSError as error:
|
|
1051
|
+
logger.error("configuration error: the command cannot start: %s", error)
|
|
1052
|
+
return EXIT_CONFIGURATION
|
|
1053
|
+
sampler = MemorySampler()
|
|
1054
|
+
try:
|
|
1055
|
+
with _heartbeats(None, worker, HEARTBEAT_S, sampler):
|
|
1056
|
+
returncode = _wait_for(process, stop, GRACE_S)
|
|
1057
|
+
finally:
|
|
1058
|
+
report = WorkerReport(
|
|
1059
|
+
worker=worker,
|
|
1060
|
+
node=os.environ.get("SIMRUN_NODE") or socket.gethostname(),
|
|
1061
|
+
processes=cgroup.cpu_quota() or os.process_cpu_count() or 1,
|
|
1062
|
+
seconds=time.monotonic() - start,
|
|
1063
|
+
peak_memory_bytes=sampler.peak,
|
|
1064
|
+
memory_limit_bytes=cgroup.memory_limit(),
|
|
1065
|
+
per_worker_gb=per_worker_gb,
|
|
1066
|
+
oom_kills=oom_kills.since(),
|
|
1067
|
+
)
|
|
1068
|
+
write_report(report, paths.task_dir, attempt)
|
|
1069
|
+
if stop.is_set():
|
|
1070
|
+
logger.info("stopped on SIGTERM, the command exited %d", returncode)
|
|
1071
|
+
return EXIT_TERMINATED
|
|
1072
|
+
if returncode == 0:
|
|
1073
|
+
return EXIT_DONE
|
|
1074
|
+
if returncode in (-signal.SIGKILL, EXIT_POOL_BROKEN):
|
|
1075
|
+
logger.error("the command was killed (exit %d)", returncode)
|
|
1076
|
+
return EXIT_POOL_BROKEN
|
|
1077
|
+
logger.error("the command failed with exit code %d", returncode)
|
|
1078
|
+
return EXIT_ANALYSIS
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def _wait_for(
|
|
1082
|
+
process: subprocess.Popen[bytes], stop: threading.Event, grace_s: float
|
|
1083
|
+
) -> int:
|
|
1084
|
+
"""Wait for a command; on `stop` send it SIGTERM, after `grace_s` SIGKILL."""
|
|
1085
|
+
deadline: float | None = None
|
|
1086
|
+
while True:
|
|
1087
|
+
try:
|
|
1088
|
+
return process.wait(timeout=POLL_S)
|
|
1089
|
+
except subprocess.TimeoutExpired:
|
|
1090
|
+
pass
|
|
1091
|
+
if stop.is_set() and deadline is None:
|
|
1092
|
+
logger.info("SIGTERM: passed on to the command, waiting %s s", grace_s)
|
|
1093
|
+
process.send_signal(signal.SIGTERM)
|
|
1094
|
+
deadline = time.monotonic() + grace_s
|
|
1095
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
1096
|
+
logger.warning("the command did not stop within %s s: killed", grace_s)
|
|
1097
|
+
process.kill()
|