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