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/cwl.py ADDED
@@ -0,0 +1,287 @@
1
+ """CWL v1.2 workflows from runs, executed with cwltool (or Toil on Slurm).
2
+
3
+ The same run as `simrun.argo`, with the same commands and environment, as one CWL
4
+ `Workflow`. The steps work on the run store by absolute path, the workflow input
5
+ `store` (a directory every step sees: a local directory, or the NFS mount on Slurm),
6
+ and communicate through it: the ledger and the outputs live in the store. The only
7
+ CWL output of a step is its standard output, which exists to order the steps.
8
+
9
+ A split task is three steps of command line tools: `<task>-w0` (worker
10
+ `<task>-w0`, `SIMRUN_ROLE=base`), the coordinator, which returns once the ledger is
11
+ complete; `<task>-extra`, scattering over the worker indices `1..pods - 1` (workers
12
+ `<task>-w<index>`, `SIMRUN_ROLE=extra`; left out for one pod), which exit when nothing
13
+ is pending; and `<task>-merge` after `<task>-w0` only. With `--parallel` the
14
+ coordinator and the extra workers run side by side; without it the steps run one after
15
+ the other and the run still ends, since either one worker computes everything or the
16
+ next finds nothing pending. A task waits for the merge of the tasks it reads from. A
17
+ failed extra worker does not fail the workflow (`successCodes` of the step), as
18
+ `continueOn` in Argo; the coordinator releases its claims and computes them, and a
19
+ deterministic unit failure fails the workflow through `<task>-w0`.
20
+
21
+ The export targets a shared file system without containers (`cwltool --no-container`,
22
+ Toil on Slurm with the store on NFS): `store` is a path, not a CWL `Directory`, and
23
+ the image is only a `DockerRequirement` hint. Running the steps in containers is
24
+ deferred to the Slurm plan.
25
+
26
+ The scratch directory of a worker is `<scratch>/<run>/<task>/<worker>` below the
27
+ workflow input `scratch` (`<run>/<task>/merge` for a merge, `<run>/<task>` for an
28
+ unsplit task). The image is a `DockerRequirement` hint, so `--no-container` runs the
29
+ commands of the environment.
30
+ """
31
+
32
+ import math
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ import yaml
37
+
38
+ from simrun import podspec
39
+ from simrun.config import ClusterConfig
40
+ from simrun.planner import PodShape, TaskPlan
41
+ from simrun.plugins import TaskType
42
+ from simrun.store import RunRecord
43
+ from simrun.workflow import TaskSpec
44
+
45
+ CWL_VERSION = "v1.2"
46
+ DEFAULT_SCRATCH = "/tmp/simrun-scratch"
47
+ # an extra worker is tolerated as `continueOn` tolerates it in Argo (Ruling 17b):
48
+ # done, analysis error, configuration error, out of memory, stopped; the coordinator
49
+ # recomputes what it released and fails itself on a deterministic error
50
+ EXTRA_SUCCESS_CODES = [0, 3, 4, 137, 143]
51
+ STORE = "$(inputs.store)"
52
+ MIB = 1024
53
+
54
+
55
+ def _env(
56
+ record: RunRecord,
57
+ task: TaskSpec,
58
+ task_type: TaskType,
59
+ shape: PodShape,
60
+ scratch: str,
61
+ worker: dict[str, str] | None = None,
62
+ ) -> list[dict[str, str]]:
63
+ """The environment of the worker harness, see `simrun.podspec`."""
64
+ values = {
65
+ **podspec.env_values(record, task, task_type, shape, STORE, scratch),
66
+ **(worker or {}),
67
+ }
68
+ return [{"envName": k, "envValue": v} for k, v in values.items()]
69
+
70
+
71
+ def _requirements(env: list[dict[str, str]], shape: PodShape) -> list[dict[str, Any]]:
72
+ requirements: list[dict[str, Any]] = [
73
+ {"class": "EnvVarRequirement", "envDef": env},
74
+ {
75
+ "class": "ResourceRequirement",
76
+ "coresMin": shape.cores,
77
+ "ramMin": math.ceil(shape.memory_gb * MIB),
78
+ },
79
+ ]
80
+ return requirements
81
+
82
+
83
+ def _tool(
84
+ name: str,
85
+ env: list[dict[str, str]],
86
+ shape: PodShape,
87
+ image: str | None,
88
+ command: list[str],
89
+ after: bool,
90
+ index: bool = False,
91
+ success_codes: list[int] | None = None,
92
+ ) -> dict[str, Any]:
93
+ """A command line tool: `command`, with the store and scratch as inputs."""
94
+ inputs: dict[str, Any] = {"store": "string", "scratch": "string"}
95
+ if after:
96
+ inputs["after"] = "File[]"
97
+ if index:
98
+ inputs["index"] = "int"
99
+ tool: dict[str, Any] = {
100
+ "class": "CommandLineTool",
101
+ "requirements": _requirements(env, shape),
102
+ "inputs": inputs,
103
+ "arguments": command,
104
+ "stdout": f"{name}.log",
105
+ "outputs": {"log": "stdout"},
106
+ }
107
+ if image is not None:
108
+ # a hint, so that `--no-container` runs the commands of the environment
109
+ tool["hints"] = [{"class": "DockerRequirement", "dockerPull": image}]
110
+ if success_codes is not None:
111
+ tool["successCodes"] = success_codes
112
+ return tool
113
+
114
+
115
+ def _step(
116
+ tool: dict[str, Any],
117
+ after: list[str],
118
+ scatter: str | None = None,
119
+ ) -> dict[str, Any]:
120
+ inputs: dict[str, Any] = {"store": "store", "scratch": "scratch"}
121
+ if after:
122
+ inputs["after"] = {"source": after, "linkMerge": "merge_nested"}
123
+ step: dict[str, Any] = {"run": tool, "in": inputs, "out": ["log"]}
124
+ if scatter is not None:
125
+ inputs["index"] = scatter
126
+ step["scatter"] = "index"
127
+ return step
128
+
129
+
130
+ def to_cwl(
131
+ record: RunRecord,
132
+ plans: dict[str, TaskPlan],
133
+ types: dict[str, TaskType],
134
+ *,
135
+ scratch: str = DEFAULT_SCRATCH,
136
+ grace_s: int = ClusterConfig.model_fields["grace_s"].default,
137
+ ) -> dict[str, Any]:
138
+ """The CWL workflow of a run.
139
+
140
+ The settings of every task are written to `<task dir>/settings.json` before the
141
+ workflow runs; the workflow does not carry them. Its inputs are `store` (the
142
+ directory of the run store) and `scratch` (the local scratch, default `scratch`).
143
+
144
+ Args:
145
+ record: The run.
146
+ plans: The plan of every task, by task id.
147
+ types: The task types, by name.
148
+ scratch: The default of the workflow input `scratch`.
149
+ grace_s: Seconds a worker has to stop after SIGTERM.
150
+
151
+ Returns:
152
+ The workflow as a CWL document.
153
+
154
+ Raises:
155
+ ValueError: A task has no image or no command, or stages a port which is no
156
+ input.
157
+ """
158
+ workflow = record.workflow
159
+ split = {t.id: types[t.type].splittable for t in workflow.tasks}
160
+ inputs: dict[str, Any] = {
161
+ "store": "string",
162
+ "scratch": {"type": "string", "default": scratch},
163
+ }
164
+ steps: dict[str, Any] = {}
165
+ outputs: dict[str, Any] = {}
166
+ run_scratch = f"$(inputs.scratch)/{record.id}"
167
+ for task in workflow.order():
168
+ task_type = types[task.type]
169
+ plan = plans[task.id]
170
+ shape = plan.shape
171
+ after = [
172
+ f"{dep}-merge/log" if split[dep] else f"{dep}/log"
173
+ for dep in task.dependencies()
174
+ ]
175
+ if not split[task.id]:
176
+ scratch = f"{run_scratch}/{task.id}"
177
+ image, argv = podspec.unsplit_command(
178
+ record, task, task_type, STORE, scratch
179
+ )
180
+ env = _env(record, task, task_type, shape, scratch)
181
+ tool = _tool(task.id, env, shape, image, argv, bool(after))
182
+ steps[task.id] = _step(tool, after)
183
+ outputs[f"{task.id}_log"] = {
184
+ "type": "File",
185
+ "outputSource": f"{task.id}/log",
186
+ }
187
+ continue
188
+ image = podspec.image(record, task, task_type)
189
+ stale = str(grace_s + podspec.STALE_MARGIN_S)
190
+ w0 = f"{task.id}-w0"
191
+ env = _env(
192
+ record,
193
+ task,
194
+ task_type,
195
+ shape,
196
+ f"{run_scratch}/{task.id}/{w0}",
197
+ {"SIMRUN_WORKER": w0, "SIMRUN_ROLE": "base", "SIMRUN_STALE_S": stale},
198
+ )
199
+ steps[w0] = _step(
200
+ _tool(w0, env, shape, image, [podspec.WORKER_COMMAND], bool(after)), after
201
+ )
202
+ if plan.pods > 1:
203
+ extra = f"{task.id}-extra"
204
+ indices = f"{task.id}_extra_indices"
205
+ inputs[indices] = {
206
+ "type": "int[]",
207
+ "default": list(range(1, plan.pods)),
208
+ }
209
+ env = _env(
210
+ record,
211
+ task,
212
+ task_type,
213
+ shape,
214
+ f"{run_scratch}/{task.id}/{task.id}-w$(inputs.index)",
215
+ {
216
+ "SIMRUN_WORKER": f"{task.id}-w$(inputs.index)",
217
+ "SIMRUN_ROLE": "extra",
218
+ "SIMRUN_STALE_S": stale,
219
+ },
220
+ )
221
+ tool = _tool(
222
+ extra,
223
+ env,
224
+ shape,
225
+ image,
226
+ [podspec.WORKER_COMMAND],
227
+ bool(after),
228
+ index=True,
229
+ success_codes=EXTRA_SUCCESS_CODES,
230
+ )
231
+ steps[extra] = _step(tool, after, scatter=indices)
232
+ merge_shape = podspec.merge_shape(shape, task_type.hint.memory_base_gb)
233
+ merge = f"{task.id}-merge"
234
+ env = _env(
235
+ record, task, task_type, merge_shape, f"{run_scratch}/{task.id}/merge"
236
+ )
237
+ steps[merge] = _step(
238
+ _tool(merge, env, merge_shape, image, [podspec.MERGE_COMMAND], True),
239
+ [f"{w0}/log"],
240
+ )
241
+ outputs[f"{task.id}_log"] = {"type": "File", "outputSource": f"{merge}/log"}
242
+ return {
243
+ "cwlVersion": CWL_VERSION,
244
+ "class": "Workflow",
245
+ "id": f"simrun-{record.id}",
246
+ "requirements": [
247
+ {"class": "ScatterFeatureRequirement"},
248
+ {"class": "MultipleInputFeatureRequirement"},
249
+ ],
250
+ "inputs": inputs,
251
+ "outputs": outputs,
252
+ "steps": steps,
253
+ }
254
+
255
+
256
+ def export_cwl(
257
+ record: RunRecord,
258
+ plans: dict[str, TaskPlan],
259
+ types: dict[str, TaskType],
260
+ path: Path,
261
+ **options: Any,
262
+ ) -> Path:
263
+ """Write the CWL workflow of a run as YAML and validate it with cwl-utils.
264
+
265
+ cwl-utils is the optional extra `cwl` (`pip install "simrun[cwl]"`), which also
266
+ installs cwltool, the runner of the workflow.
267
+
268
+ Args:
269
+ record: The run.
270
+ plans: The plan of every task, by task id.
271
+ types: The task types, by name.
272
+ path: The file to write.
273
+ **options: The keyword arguments of `to_cwl`.
274
+
275
+ Returns:
276
+ The path.
277
+ """
278
+ try:
279
+ from cwl_utils.parser import load_document_by_uri
280
+ except ImportError as error:
281
+ raise ImportError(
282
+ 'the CWL export needs cwl-utils: pip install "simrun[cwl]"'
283
+ ) from error
284
+ document = to_cwl(record, plans, types, **options)
285
+ path.write_text(yaml.safe_dump(document, sort_keys=False))
286
+ load_document_by_uri(path)
287
+ return path
simrun/demo.py ADDED
@@ -0,0 +1,106 @@
1
+ """The demo plugin: sums of squares, split into one unit per number.
2
+
3
+ A unit writes its square into the output port `squares` (`out/squares/<i>.json`), the
4
+ merge the sum into the summary of the task (`out/summary.json`, which `compare`
5
+ reads; no port).
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import time
11
+ import uuid
12
+
13
+ from pydantic import BaseModel, Field
14
+
15
+ from simrun.plugins import ResourceHint, TaskPaths, TaskType
16
+
17
+
18
+ class SquaresSettings(BaseModel):
19
+ """Settings of the squares demo.
20
+
21
+ Attributes:
22
+ n: Number of units.
23
+ sleep_s: Seconds each unit sleeps.
24
+ fail_at: Index of a unit which fails.
25
+ memory_mb: Megabytes each unit allocates.
26
+ """
27
+
28
+ n: int = Field(title="Units")
29
+ sleep_s: float = Field(0.0, title="Sleep per unit (s)")
30
+ fail_at: int | None = Field(None, title="Fail at unit")
31
+ memory_mb: int = Field(0, title="Memory per unit (MB)")
32
+
33
+
34
+ def count_units(settings: SquaresSettings) -> int:
35
+ """Count the units of work.
36
+
37
+ Args:
38
+ settings: Settings of the task.
39
+
40
+ Returns:
41
+ The number of units.
42
+ """
43
+ return settings.n
44
+
45
+
46
+ PORT = "squares"
47
+
48
+
49
+ def setup(settings: SquaresSettings, paths: TaskPaths) -> None:
50
+ """Create the directory of the output port, once per process.
51
+
52
+ Args:
53
+ settings: Settings of the task.
54
+ paths: Locations of the task.
55
+ """
56
+ (paths.out / PORT).mkdir(parents=True, exist_ok=True)
57
+
58
+
59
+ def compute_unit(settings: SquaresSettings, index: int, paths: TaskPaths) -> None:
60
+ """Write the square of the index, atomically (temporary file, rename).
61
+
62
+ Args:
63
+ settings: Settings of the task.
64
+ index: Index of the unit.
65
+ paths: Locations of the task.
66
+
67
+ Raises:
68
+ RuntimeError: The unit is the one set to fail.
69
+ """
70
+ ballast = bytearray(settings.memory_mb * 2**20)
71
+ ballast[::4096] = b"\x01" * len(ballast[::4096])
72
+ time.sleep(settings.sleep_s)
73
+ if settings.fail_at == index:
74
+ raise RuntimeError(f"unit {index} failed on request")
75
+ units = paths.out / PORT
76
+ units.mkdir(parents=True, exist_ok=True)
77
+ tmp = units / f".{index}.json.{uuid.uuid4().hex}.tmp"
78
+ tmp.write_text(json.dumps({"i": index, "square": index * index}))
79
+ os.replace(tmp, units / f"{index}.json")
80
+
81
+
82
+ def merge(settings: SquaresSettings, paths: TaskPaths) -> None:
83
+ """Write the sum of the squares.
84
+
85
+ Args:
86
+ settings: Settings of the task.
87
+ paths: Locations of the task.
88
+ """
89
+ total = sum(
90
+ json.loads((paths.out / PORT / f"{i}.json").read_text())["square"]
91
+ for i in range(settings.n)
92
+ )
93
+ (paths.out / "summary.json").write_text(json.dumps({"sum": total, "n": settings.n}))
94
+
95
+
96
+ SQUARES = TaskType(
97
+ name="demo.squares",
98
+ settings=SquaresSettings,
99
+ outputs=(PORT,),
100
+ hint=ResourceHint(memory_base_gb=0.5, memory_per_worker_gb=0.5),
101
+ count_units="simrun.demo:count_units",
102
+ setup="simrun.demo:setup",
103
+ compute_unit="simrun.demo:compute_unit",
104
+ merge="simrun.demo:merge",
105
+ description="Squares of numbers, split over servers",
106
+ )