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/__init__.py
ADDED
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
|