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 ADDED
@@ -0,0 +1,3 @@
1
+ """simrun: a runner of simulation workflows on a k3s cluster."""
2
+
3
+ __version__ = "0.1.0"
simrun/argo.py ADDED
@@ -0,0 +1,449 @@
1
+ """Argo workflows from runs: one DAG, a scatter of workers per split task.
2
+
3
+ A split task of `pods` pods is three DAG tasks of one worker template running
4
+ `simrun-worker`: `<task>-w0`, the coordinator (worker `<task>-w0`, `SIMRUN_ROLE=base`,
5
+ base priority), which returns once the ledger of the task is complete; `<task>-extra`
6
+ over `withSequence: {start: 1, count: pods - 1}` (workers `<task>-w<item>`,
7
+ `SIMRUN_ROLE=extra`, extra priority; left out for one pod), which exit when nothing is
8
+ pending; and `<task>-merge` running `simrun-merge`, which depends on `<task>-w0` only,
9
+ so extra workers still waiting for admission never hold up the merge. An unsplit task
10
+ is one pod running its command. A task waits for the tasks it reads from, a split one
11
+ for their merge.
12
+
13
+ The extra workers continue on failure (`continueOn: {failed: true, error: true}`), so
14
+ an extra worker which fails does not fail the run; the coordinator releases the
15
+ claims of workers whose heartbeat is older than `SIMRUN_STALE_S` (`grace_s + 60`) and
16
+ computes them. The workers carry the input parameters `worker`, `priority`, `task`
17
+ and `role`. Extra workers may still be pending or running after the last merge of a
18
+ run: the controller stops the whole workflow then and records the state of the run
19
+ itself.
20
+
21
+ Every pod carries the Kueue label of the LocalQueue, the WorkloadPriorityClass label
22
+ and the Kubernetes `priorityClassName` (the coordinator, the merge and an unsplit task
23
+ the base priority, the extra workers the extra priority), the annotation which requires
24
+ one node per pod (topology aware scheduling), requests equal to limits, the run
25
+ directory of the store mounted read-write and the snapshots read-only, a scratch
26
+ directory on the local disk (`<run>/<task>/<worker>` for a worker, `<run>/<task>/merge`
27
+ for a merge, `<run>/<task>` for an unsplit task), and the environment of the worker
28
+ harness (`simrun.worker`). The label `simrun/group` is the group made a valid label
29
+ value (`label_value`), the annotation `simrun/group` the group as given. The pods run
30
+ as the service account `ClusterConfig.service_account`, which the wait container of
31
+ the executor needs to write its results. The pods of a run with a reserved node select
32
+ the node by its label `simrun/reserved=<run id>` and tolerate its taint of the same
33
+ key and value whatever its effect (the backend taints with `NoExecute`).
34
+
35
+ The Argo features this relies on (Argo Workflows v4.1.6):
36
+
37
+ - `retryStrategy.expression` with `lastRetry.exitCode`, a string ("-1" when the pod
38
+ has no exit code), and `retryPolicy: Always`, which must hold together to retry
39
+ (docs/retries.md, "Conditional retries"; the type in docs/variables.md,
40
+ "RetryStrategy"). The expression compares strings, since `"3" != 3` holds in expr.
41
+ - `{{retries}}` and `lastRetry.*` in a container template with a retry strategy,
42
+ substituted per attempt, `lastRetry.exitCode` "0" in the first attempt
43
+ (docs/variables.md, "Container/Script Templates"; `executeTemplate` in
44
+ workflow/controller/operator.go), also in `{{=...}}` expressions, which are left
45
+ for the attempt until `lastRetry` is known (`variablesToCheck` in
46
+ util/template/expression_template.go). So `podSpecPatch` sets the memory and
47
+ `SIMRUN_MEMORY_PER_WORKER_GB` of an attempt after an exit 137 to the retry values,
48
+ and every other attempt keeps the first values.
49
+ - `podSpecPatch`, a strategic merge patch of the pod spec (containers and env merged
50
+ by name), also for `terminationGracePeriodSeconds`, which a template has no field
51
+ for; the emissary executor reads it after the patch (`createWorkflowPod` in
52
+ workflow/controller/workflowpod.go).
53
+ - `{{item}}` in the arguments of an expanded DAG task (`processItem` in
54
+ workflow/controller/operator.go), and input parameters substituted anywhere in a
55
+ template, its metadata and `priorityClassName` included (`SubstituteParams` in
56
+ workflow/common/util.go).
57
+ - `continueOn` of a task which no task depends on: the DAG ignores its failure
58
+ (`assessDAGPhase` in workflow/controller/dag.go).
59
+ """
60
+
61
+ import hashlib
62
+ import json
63
+ import re
64
+ from typing import Any
65
+
66
+ from hera.workflows import Workflow
67
+ from hera.workflows import models as m
68
+
69
+ from simrun import podspec
70
+ from simrun.config import ClusterConfig
71
+ from simrun.planner import PodShape, TaskPlan
72
+ from simrun.plugins import TaskType
73
+ from simrun.store import RunRecord
74
+ from simrun.worker import (
75
+ EXIT_ANALYSIS,
76
+ EXIT_CONFIGURATION,
77
+ EXIT_POOL_BROKEN,
78
+ )
79
+ from simrun.workflow import TaskSpec
80
+
81
+ QUEUE_LABEL = "kueue.x-k8s.io/queue-name"
82
+ PRIORITY_LABEL = "kueue.x-k8s.io/priority-class"
83
+ TOPOLOGY_ANNOTATION = "kueue.x-k8s.io/podset-required-topology"
84
+ TOPOLOGY_LEVEL = "kubernetes.io/hostname"
85
+ RESERVED_KEY = "simrun/reserved"
86
+ RUN_LABEL = "simrun/run"
87
+ GROUP_LABEL = "simrun/group"
88
+ GPU_RESOURCE = "nvidia.com/gpu"
89
+ LABEL_MAX = 63
90
+ LABEL_HASH = 8
91
+ # The retries of a pod: an eviction by Kueue (a preemption, a take-back) counts as
92
+ # one, and a pod of a long run of low priority on a busy cluster is evicted many
93
+ # times. A second kill for memory ends the run in the controller (Ruling 35), exits 3
94
+ # and 4 are not retried, so the limit bounds only failures of other kinds (a crash,
95
+ # a lost node) at a hundred attempts.
96
+ RETRY_LIMIT = 100
97
+ RETRY_EXPRESSION = (
98
+ f"lastRetry.exitCode != '{EXIT_ANALYSIS}' "
99
+ f"&& lastRetry.exitCode != '{EXIT_CONFIGURATION}'"
100
+ )
101
+ # seconds a pod gets beyond the grace of the worker, for the executor to finish
102
+ GRACE_MARGIN_S = 30
103
+ # seconds beyond the grace after which the coordinator takes a worker as gone
104
+ WORKER_PARAMETERS = ("worker", "priority", "task", "role")
105
+ ENTRYPOINT = "dag"
106
+ CONTAINER = "main"
107
+ STORE_VOLUME = "store"
108
+ SCRATCH_VOLUME = "scratch"
109
+
110
+
111
+ def workflow_name(run_id: str) -> str:
112
+ """The name of the workflow of a run."""
113
+ return f"simrun-{run_id}"
114
+
115
+
116
+ def _gi(value: float) -> str:
117
+ """A quantity of GiB, e.g. `3Gi` or `2.5Gi`."""
118
+ return f"{int(value)}Gi" if value == int(value) else f"{value}Gi"
119
+
120
+
121
+ def _on_oom(retry: str, first: str) -> str:
122
+ """An expression of an attempt: `retry` after an out of memory exit, else `first`."""
123
+ return (
124
+ f"{{{{=lastRetry.exitCode == '{EXIT_POOL_BROKEN}' ? '{retry}' : '{first}'}}}}"
125
+ )
126
+
127
+
128
+ def label_value(text: str) -> str:
129
+ """A valid label value for free text.
130
+
131
+ Lowercase, every character but `[a-z0-9-_.]` replaced by `-`, starting and ending
132
+ alphanumeric, at most 63 characters; a value which had to be changed ends in a
133
+ hash of the text, so different texts keep different values.
134
+
135
+ Args:
136
+ text: The text.
137
+
138
+ Returns:
139
+ The label value, `text` itself when it is one.
140
+ """
141
+ value = re.sub(r"[^a-z0-9._-]", "-", text.lower()).strip("-_.")
142
+ if value == text and len(value) <= LABEL_MAX:
143
+ return value
144
+ digest = hashlib.sha256(text.encode()).hexdigest()[:LABEL_HASH]
145
+ stem = value[: LABEL_MAX - LABEL_HASH - 1].rstrip("-_.")
146
+ return f"{stem}-{digest}" if stem else digest
147
+
148
+
149
+ def _parameter(name: str) -> str:
150
+ """The reference to an input parameter of a template."""
151
+ return f"{{{{inputs.parameters.{name}}}}}"
152
+
153
+
154
+ def _worker_arguments(worker: str, priority: str, task: str, role: str) -> m.Arguments:
155
+ """The arguments of the worker template, in the order of `WORKER_PARAMETERS`."""
156
+ values = dict(zip(WORKER_PARAMETERS, (worker, priority, task, role), strict=True))
157
+ return m.Arguments(
158
+ parameters=[m.Parameter(name=k, value=v) for k, v in values.items()]
159
+ )
160
+
161
+
162
+ def _resources(shape: PodShape) -> m.ResourceRequirements:
163
+ amounts = {"cpu": str(shape.cores), "memory": _gi(shape.memory_gb)}
164
+ if shape.gpus > 0:
165
+ amounts[GPU_RESOURCE] = str(shape.gpus)
166
+ if shape.scratch_gb > 0:
167
+ amounts["ephemeral-storage"] = _gi(shape.scratch_gb)
168
+ quantities = {k: m.Quantity(v) for k, v in amounts.items()}
169
+ return m.ResourceRequirements(requests=quantities, limits=dict(quantities))
170
+
171
+
172
+ def _pod_spec_patch(shape: PodShape, config: ClusterConfig) -> str:
173
+ """The patch of an attempt: the retry memory after an exit 137, and the grace."""
174
+ memory = _on_oom(_gi(shape.retry_memory_gb), _gi(shape.memory_gb))
175
+ per_worker = _on_oom(
176
+ podspec.number(shape.retry_per_worker_gb), podspec.number(shape.per_worker_gb)
177
+ )
178
+ patch = {
179
+ "terminationGracePeriodSeconds": config.grace_s + GRACE_MARGIN_S,
180
+ "containers": [
181
+ {
182
+ "name": CONTAINER,
183
+ "resources": {
184
+ "requests": {"memory": memory},
185
+ "limits": {"memory": memory},
186
+ },
187
+ "env": [{"name": "SIMRUN_MEMORY_PER_WORKER_GB", "value": per_worker}],
188
+ }
189
+ ],
190
+ }
191
+ return json.dumps(patch)
192
+
193
+
194
+ def _env(
195
+ record: RunRecord,
196
+ task: TaskSpec,
197
+ task_type: TaskType,
198
+ shape: PodShape,
199
+ config: ClusterConfig,
200
+ worker: dict[str, str] | None = None,
201
+ ) -> list[m.EnvVar]:
202
+ """The environment of a pod; `worker` holds the variables of a worker pod."""
203
+ values = {
204
+ **podspec.env_values(
205
+ record, task, task_type, shape, config.store_mount, config.scratch_path
206
+ ),
207
+ "SIMRUN_ATTEMPT": "{{retries}}",
208
+ **(worker or {}),
209
+ }
210
+ env = [m.EnvVar(name=k, value=v) for k, v in values.items()]
211
+ node = m.EnvVarSource(field_ref=m.ObjectFieldSelector(field_path="spec.nodeName"))
212
+ env.append(m.EnvVar(name="SIMRUN_NODE", value_from=node))
213
+ return env
214
+
215
+
216
+ def _mounts(
217
+ record: RunRecord, scratch: str, config: ClusterConfig
218
+ ) -> list[m.VolumeMount]:
219
+ """The mounts of a pod; `scratch` is its directory below the scratch path."""
220
+ run = f"runs/{record.id}"
221
+ return [
222
+ m.VolumeMount(
223
+ name=STORE_VOLUME, mount_path=f"{config.store_mount}/{run}", sub_path=run
224
+ ),
225
+ m.VolumeMount(
226
+ name=STORE_VOLUME,
227
+ mount_path=f"{config.store_mount}/snapshots",
228
+ sub_path="snapshots",
229
+ read_only=True,
230
+ ),
231
+ m.VolumeMount(
232
+ name=SCRATCH_VOLUME,
233
+ mount_path=config.scratch_path,
234
+ sub_path=scratch,
235
+ ),
236
+ ]
237
+
238
+
239
+ def _template(
240
+ name: str,
241
+ image: str,
242
+ command: list[str],
243
+ env: list[m.EnvVar],
244
+ mounts: list[m.VolumeMount],
245
+ shape: PodShape,
246
+ priority: str,
247
+ config: ClusterConfig,
248
+ inputs: list[str] | None = None,
249
+ ) -> m.Template:
250
+ """A pod template with its Kueue metadata, resources and retries."""
251
+ return m.Template(
252
+ name=name,
253
+ inputs=m.Inputs(parameters=[m.Parameter(name=p) for p in inputs])
254
+ if inputs
255
+ else None,
256
+ metadata=m.Metadata(
257
+ labels={QUEUE_LABEL: config.local_queue, PRIORITY_LABEL: priority},
258
+ annotations={TOPOLOGY_ANNOTATION: TOPOLOGY_LEVEL},
259
+ ),
260
+ priority_class_name=priority,
261
+ container=m.Container(
262
+ name=CONTAINER,
263
+ image=image,
264
+ command=command or None,
265
+ env=env,
266
+ resources=_resources(shape),
267
+ volume_mounts=mounts,
268
+ ),
269
+ retry_strategy=m.RetryStrategy(
270
+ limit=m.IntOrString(RETRY_LIMIT),
271
+ retry_policy="Always",
272
+ expression=RETRY_EXPRESSION,
273
+ ),
274
+ pod_spec_patch=_pod_spec_patch(shape, config),
275
+ )
276
+
277
+
278
+ def to_argo(
279
+ record: RunRecord,
280
+ plans: dict[str, TaskPlan],
281
+ types: dict[str, TaskType],
282
+ config: ClusterConfig,
283
+ ) -> dict[str, Any]:
284
+ """The Argo workflow of a run.
285
+
286
+ The settings of every task are written to `<task dir>/settings.json` before the
287
+ workflow is submitted; the manifest does not carry them.
288
+
289
+ Args:
290
+ record: The run.
291
+ plans: The plan of every task, by task id.
292
+ types: The task types, by name.
293
+ config: The cluster.
294
+
295
+ Returns:
296
+ The manifest of the workflow.
297
+
298
+ Raises:
299
+ ValueError: A task has no image or no command, or stages a port which is no
300
+ input.
301
+ """
302
+ workflow = record.workflow
303
+ split = {t.id: types[t.type].splittable for t in workflow.tasks}
304
+ templates: list[m.Template] = []
305
+ tasks: list[m.DAGTask] = []
306
+ for task in workflow.order():
307
+ task_type = types[task.type]
308
+ plan = plans[task.id]
309
+ shape = plan.shape
310
+ dependencies = [
311
+ f"{dep}-merge" if split[dep] else dep for dep in task.dependencies()
312
+ ] or None
313
+ if not split[task.id]:
314
+ image, argv = podspec.unsplit_command(
315
+ record, task, task_type, config.store_mount, config.scratch_path
316
+ )
317
+ env = _env(record, task, task_type, shape, config)
318
+ name = f"{task.id}-run"
319
+ mounts = _mounts(record, f"{record.id}/{task.id}", config)
320
+ templates.append(
321
+ _template(
322
+ name, image, argv, env, mounts, shape, plan.base_priority, config
323
+ )
324
+ )
325
+ tasks.append(
326
+ m.DAGTask(name=task.id, template=name, dependencies=dependencies)
327
+ )
328
+ continue
329
+ image = podspec.image(record, task, task_type)
330
+ worker = _parameter("worker")
331
+ worker_env = _env(
332
+ record,
333
+ task,
334
+ task_type,
335
+ shape,
336
+ config,
337
+ {
338
+ "SIMRUN_WORKER": worker,
339
+ "SIMRUN_ROLE": _parameter("role"),
340
+ "SIMRUN_STALE_S": str(config.grace_s + podspec.STALE_MARGIN_S),
341
+ },
342
+ )
343
+ templates.append(
344
+ _template(
345
+ f"{task.id}-worker",
346
+ image,
347
+ [podspec.WORKER_COMMAND],
348
+ worker_env,
349
+ _mounts(record, f"{record.id}/{task.id}/{worker}", config),
350
+ shape,
351
+ _parameter("priority"),
352
+ config,
353
+ inputs=list(WORKER_PARAMETERS),
354
+ )
355
+ )
356
+ tasks.append(
357
+ m.DAGTask(
358
+ name=f"{task.id}-w0",
359
+ template=f"{task.id}-worker",
360
+ dependencies=dependencies,
361
+ arguments=_worker_arguments(
362
+ f"{task.id}-w0", plan.base_priority, task.id, "base"
363
+ ),
364
+ )
365
+ )
366
+ if plan.pods > 1:
367
+ tasks.append(
368
+ m.DAGTask(
369
+ name=f"{task.id}-extra",
370
+ template=f"{task.id}-worker",
371
+ dependencies=dependencies,
372
+ continue_on=m.ContinueOn(failed=True, error=True),
373
+ with_sequence=m.Sequence(
374
+ start=m.IntOrString("1"),
375
+ count=m.IntOrString(str(plan.pods - 1)),
376
+ ),
377
+ arguments=_worker_arguments(
378
+ f"{task.id}-w{{{{item}}}}",
379
+ plan.extra_priority,
380
+ task.id,
381
+ "extra",
382
+ ),
383
+ )
384
+ )
385
+ merge_shape = podspec.merge_shape(shape, task_type.hint.memory_base_gb)
386
+ merge_env = _env(record, task, task_type, merge_shape, config)
387
+ templates.append(
388
+ _template(
389
+ f"{task.id}-merge",
390
+ image,
391
+ [podspec.MERGE_COMMAND],
392
+ merge_env,
393
+ _mounts(record, f"{record.id}/{task.id}/merge", config),
394
+ merge_shape,
395
+ plan.base_priority,
396
+ config,
397
+ )
398
+ )
399
+ tasks.append(
400
+ m.DAGTask(
401
+ name=f"{task.id}-merge",
402
+ template=f"{task.id}-merge",
403
+ dependencies=[f"{task.id}-w0"],
404
+ )
405
+ )
406
+ templates.insert(0, m.Template(name=ENTRYPOINT, dag=m.DAGTemplate(tasks=tasks)))
407
+
408
+ labels = {RUN_LABEL: record.id}
409
+ annotations = None
410
+ if record.group is not None:
411
+ labels[GROUP_LABEL] = label_value(record.group)
412
+ annotations = {GROUP_LABEL: record.group}
413
+ node_selector = None
414
+ tolerations = None
415
+ if record.reserve is not None:
416
+ node_selector = {RESERVED_KEY: record.id}
417
+ # no effect: tolerates the taint of the reservation whatever its effect
418
+ tolerations = [
419
+ m.Toleration(key=RESERVED_KEY, operator="Equal", value=record.id)
420
+ ]
421
+ volumes = [
422
+ m.Volume(
423
+ name=STORE_VOLUME,
424
+ persistent_volume_claim=m.PersistentVolumeClaimVolumeSource(
425
+ claim_name=config.store_claim
426
+ ),
427
+ ),
428
+ m.Volume(
429
+ name=SCRATCH_VOLUME,
430
+ host_path=m.HostPathVolumeSource(
431
+ path=config.scratch_path, type="DirectoryOrCreate"
432
+ ),
433
+ ),
434
+ ]
435
+ return Workflow(
436
+ name=workflow_name(record.id),
437
+ namespace=config.namespace,
438
+ labels=labels,
439
+ annotations=annotations,
440
+ entrypoint=ENTRYPOINT,
441
+ service_account_name=config.service_account,
442
+ templates=list(templates), # a list of the union hera declares
443
+ volumes=volumes,
444
+ node_selector=node_selector,
445
+ tolerations=tolerations,
446
+ ttl_strategy=m.TTLStrategy(
447
+ seconds_after_completion=config.ttl_after_finished_s
448
+ ),
449
+ ).to_dict()
@@ -0,0 +1,20 @@
1
+ # The versions of the build backends of the environments of the runs: the
2
+ # requirements of their build systems and what a backend asks for an editable
3
+ # build are resolved with these pins (when the uv.lock of the model does not
4
+ # have them), so a new release of a backend changes no lock and rebuilds no
5
+ # image. Raise a pin here (or point `build_constraints` of the service settings
6
+ # at a file of your own); the images of the models which use it are built again.
7
+ editables==0.6
8
+ flit-core==4.2.0
9
+ hatch-vcs==0.5.0
10
+ hatchling==1.33.0
11
+ packaging==26.3
12
+ pathspec==1.1.1
13
+ pdm-backend==2.5.0
14
+ pluggy==1.7.0
15
+ poetry-core==2.5.0
16
+ setuptools==84.2.1
17
+ setuptools-scm==10.3.4
18
+ trove-classifiers==2026.9.21.13
19
+ uv-build==0.13.7
20
+ wheel==0.48.0
simrun/cgroup.py ADDED
@@ -0,0 +1,77 @@
1
+ """The limits and the usage of the cgroup (v2) a worker runs in."""
2
+
3
+ from pathlib import Path
4
+
5
+ CGROUP_ROOT = Path("/sys/fs/cgroup")
6
+
7
+
8
+ def _read(path: Path) -> str | None:
9
+ try:
10
+ return path.read_text().strip()
11
+ except OSError:
12
+ return None
13
+
14
+
15
+ def cpu_quota(root: Path = CGROUP_ROOT) -> int | None:
16
+ """The number of cores the CPU quota of `cpu.max` allows.
17
+
18
+ Args:
19
+ root: The directory of the cgroup.
20
+
21
+ Returns:
22
+ The quota over the period, rounded down and at least 1, or `None` without a
23
+ quota or without the file.
24
+ """
25
+ text = _read(root / "cpu.max")
26
+ if text is None:
27
+ return None
28
+ quota, _, period = text.partition(" ")
29
+ if quota == "max":
30
+ return None
31
+ return max(1, int(quota) // int(period or "100000"))
32
+
33
+
34
+ def memory_limit(root: Path = CGROUP_ROOT) -> int | None:
35
+ """The memory limit of `memory.max` in bytes, `None` without a limit."""
36
+ text = _read(root / "memory.max")
37
+ if text is None or text == "max":
38
+ return None
39
+ return int(text)
40
+
41
+
42
+ def memory_anon(root: Path = CGROUP_ROOT) -> int | None:
43
+ """The anonymous memory of `memory.stat` (`anon`) in bytes.
44
+
45
+ The memory the processes allocated, without the page cache which `memory.peak`
46
+ counts and the kernel reclaims at the limit instead of killing.
47
+
48
+ Returns:
49
+ The bytes, `None` without the file or the field.
50
+ """
51
+ text = _read(root / "memory.stat")
52
+ if text is None:
53
+ return None
54
+ for line in text.splitlines():
55
+ key, _, value = line.partition(" ")
56
+ if key == "anon":
57
+ return int(value)
58
+ return None
59
+
60
+
61
+ def memory_oom_kills(root: Path = CGROUP_ROOT) -> int | None:
62
+ """The processes of the cgroup the out of memory killer killed (`oom_kill`).
63
+
64
+ The counter of `memory.events`, the evidence of a kill for memory: a process
65
+ which exits 137 may have been killed by anyone with SIGKILL.
66
+
67
+ Returns:
68
+ The count, `None` without the file or the field.
69
+ """
70
+ text = _read(root / "memory.events")
71
+ if text is None:
72
+ return None
73
+ for line in text.splitlines():
74
+ key, _, value = line.partition(" ")
75
+ if key == "oom_kill":
76
+ return int(value)
77
+ return None