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/podspec.py ADDED
@@ -0,0 +1,166 @@
1
+ """The parts of a pod which every backend shares: image, shapes, command, environment.
2
+
3
+ `simrun.argo` and `simrun.cwl` run the same commands with the same environment; they
4
+ differ in where the run store and the scratch directory are (a mount, a path) and in
5
+ how a worker is named.
6
+ """
7
+
8
+ import math
9
+ import shlex
10
+
11
+ from simrun.planner import PodShape
12
+ from simrun.plugins import TaskType
13
+ from simrun.plugins_builtin import COMMAND, CommandSettings
14
+ from simrun.store import RunRecord
15
+ from simrun.worker import PROVENANCE_FILE, SETTINGS_FILE
16
+ from simrun.workflow import TaskSpec
17
+
18
+ WORKER_COMMAND = "simrun-worker"
19
+ MERGE_COMMAND = "simrun-merge"
20
+ EXEC_COMMAND = "simrun-exec"
21
+ # seconds beyond the grace after which the coordinator takes a worker as gone
22
+ STALE_MARGIN_S = 60
23
+
24
+
25
+ def number(value: float) -> str:
26
+ """A number of GiB for the environment, e.g. `0.5`."""
27
+ return str(float(value))
28
+
29
+
30
+ def task_dir(record: RunRecord, task_id: str, store: str) -> str:
31
+ """The directory of a task below the store root `store`."""
32
+ return f"{store}/runs/{record.id}/tasks/{task_id}"
33
+
34
+
35
+ def output(record: RunRecord, ref: str) -> str:
36
+ """The output a reference `<task>.<port>` names, relative to the store."""
37
+ task_id, _, port = ref.partition(".")
38
+ return f"runs/{record.id}/tasks/{task_id}/out/{port}"
39
+
40
+
41
+ def env_values(
42
+ record: RunRecord,
43
+ task: TaskSpec,
44
+ task_type: TaskType,
45
+ shape: PodShape,
46
+ store: str,
47
+ scratch: str,
48
+ ) -> dict[str, str]:
49
+ """The environment of the worker harness, see `simrun.worker`.
50
+
51
+ Args:
52
+ record: The run.
53
+ task: The task.
54
+ task_type: Its type.
55
+ shape: The pod shape.
56
+ store: The root of the store as the pod sees it.
57
+ scratch: The scratch directory of the pod.
58
+
59
+ Returns:
60
+ The variables by name; the caller adds the ones of its backend.
61
+
62
+ Raises:
63
+ ValueError: The task stages a port which is no input.
64
+ """
65
+ inputs = {port: output(record, ref) for port, ref in task.inputs.items()}
66
+ for port in task.stage:
67
+ if port not in inputs:
68
+ raise ValueError(f"task {task.id!r} stages {port!r}, which is no input")
69
+ directory = task_dir(record, task.id, store)
70
+ return {
71
+ "SIMRUN_TASK_DIR": directory,
72
+ "SIMRUN_TASK_TYPE": task_type.name,
73
+ "SIMRUN_MEMORY_BASE_GB": number(task_type.hint.memory_base_gb),
74
+ "SIMRUN_MEMORY_PER_WORKER_GB": number(shape.per_worker_gb),
75
+ "SIMRUN_INPUTS": ",".join(f"{port}={path}" for port, path in inputs.items()),
76
+ "SIMRUN_STAGE": ",".join(
77
+ f"{inputs[port]}={scratch}/{port}" for port in task.stage
78
+ ),
79
+ "SIMRUN_EDITABLE": ",".join(f"{store}/{s.snapshot}" for s in record.sources),
80
+ "SIMRUN_SCRATCH": scratch,
81
+ "SIMRUN_PROVENANCE": f"{directory}/{PROVENANCE_FILE}",
82
+ }
83
+
84
+
85
+ def image(record: RunRecord, task: TaskSpec, task_type: TaskType) -> str:
86
+ """The image of a task: the one of its type, else the one of the run.
87
+
88
+ Raises:
89
+ ValueError: The type has no image and the run has none.
90
+ """
91
+ if task_type.image is not None:
92
+ return task_type.image
93
+ if record.image is None:
94
+ raise ValueError(
95
+ f"task {task.id!r} of type {task_type.name!r} needs the image of the run, "
96
+ "and the run has none"
97
+ )
98
+ return record.image
99
+
100
+
101
+ def merge_shape(shape: PodShape, base_gb: float) -> PodShape:
102
+ """The merge: one core and the memory of one worker process besides the base."""
103
+ return PodShape(
104
+ cores=1,
105
+ memory_gb=math.ceil(base_gb + shape.per_worker_gb),
106
+ processes=1,
107
+ per_worker_gb=shape.per_worker_gb,
108
+ retry_memory_gb=math.ceil(base_gb + shape.retry_per_worker_gb),
109
+ retry_per_worker_gb=shape.retry_per_worker_gb,
110
+ scratch_gb=0.0,
111
+ gpus=0,
112
+ )
113
+
114
+
115
+ def unsplit_command(
116
+ record: RunRecord, task: TaskSpec, task_type: TaskType, store: str, scratch: str
117
+ ) -> tuple[str, list[str]]:
118
+ """The image and argv of an unsplit task.
119
+
120
+ The command of a plugin type runs through `simrun-exec`, which installs the
121
+ snapshots, sets the thread variables and writes a report; a `command` task runs
122
+ its argv as given in its image. In the command template of a type, `{settings}`
123
+ is the path of the settings file and `{out}` the output directory of the task,
124
+ and a part with `{input}` is repeated once per input port of the task, sorted by
125
+ port, with `{input}` replaced by `<port>=<path>`, the output the port reads as
126
+ the pod sees it, or `<scratch>/<port>` for a staged port, the copy
127
+ `simrun-exec` stages; without inputs the part is dropped.
128
+
129
+ Args:
130
+ record: The run.
131
+ task: The task.
132
+ task_type: Its type.
133
+ store: The root of the store as the pod sees it.
134
+ scratch: The scratch directory of the pod, as in `env_values`.
135
+
136
+ Returns:
137
+ The image and the argv.
138
+
139
+ Raises:
140
+ ValueError: The type is neither splittable nor has a command.
141
+ """
142
+ if task_type.name == COMMAND.name:
143
+ settings = CommandSettings.model_validate(task.settings)
144
+ return settings.image, settings.args
145
+ if task_type.command is None:
146
+ raise ValueError(
147
+ f"task {task.id!r}: type {task_type.name!r} is neither splittable "
148
+ "nor has a command"
149
+ )
150
+ directory = task_dir(record, task.id, store)
151
+ inputs = [
152
+ f"{port}={scratch}/{port}"
153
+ if port in task.stage
154
+ else f"{port}={store}/{output(record, task.inputs[port])}"
155
+ for port in sorted(task.inputs)
156
+ ]
157
+ argv = [EXEC_COMMAND]
158
+ for template in shlex.split(task_type.command):
159
+ part = template.replace("{settings}", f"{directory}/{SETTINGS_FILE}").replace(
160
+ "{out}", f"{directory}/out"
161
+ )
162
+ if "{input}" in part:
163
+ argv += [part.replace("{input}", value) for value in inputs]
164
+ else:
165
+ argv.append(part)
166
+ return image(record, task, task_type), argv
simrun/resources.py ADDED
@@ -0,0 +1,270 @@
1
+ """Resources learned from the reports of workers.
2
+
3
+ An observation is the memory per worker process and the seconds of a unit in one
4
+ process of one report of a worker, keyed by the task type, the model and a variant. A request is
5
+ resolved from the last `WINDOW` observations of its key: the 95th percentile of the
6
+ measured memory per worker times `MARGIN`, and at least `RETRY_FACTOR` times the
7
+ memory per worker of a pod killed for memory, until a later run of the key
8
+ succeeded (its measurement then says what the key needs). The memory measured is
9
+ anonymous memory (see `simrun.worker.MemorySampler`), and a worker counts as out of
10
+ memory only when the backend reports its pod killed for memory.
11
+ """
12
+
13
+ import json
14
+ import logging
15
+ import math
16
+ import sqlite3
17
+ import time
18
+ from collections.abc import Mapping
19
+ from contextlib import closing
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from pydantic import ValidationError
25
+
26
+ from simrun.plugins import ResourceHint, TaskType
27
+ from simrun.worker import WorkerReport
28
+ from simrun.workflow import ResourceOverride
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ RETRY_FACTOR = 1.5
33
+ MARGIN = 1.10
34
+ DEFAULT_PER_WORKER_GB = 6.0
35
+ GIB = 2**30
36
+ PERCENTILE = 0.95
37
+ MIN_PER_WORKER_GB = 0.1
38
+ # the observations of a key a request is resolved from, the most recent ones
39
+ WINDOW = 20
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class ResourceKey:
44
+ """What observations of a task are grouped by.
45
+
46
+ Attributes:
47
+ task_type: Name of the task type.
48
+ model: Name of the first source, empty without.
49
+ variant: A setting the plugin names as cost relevant, empty by default.
50
+ """
51
+
52
+ task_type: str
53
+ model: str
54
+ variant: str = ""
55
+
56
+ def text(self) -> str:
57
+ """Returns the key as stored."""
58
+ return "\x1f".join((self.task_type, self.model, self.variant))
59
+
60
+
61
+ def resource_key(
62
+ task_type: TaskType, settings: Mapping[str, Any], model: str
63
+ ) -> ResourceKey:
64
+ """The key of the observations of a task, the one place it is derived.
65
+
66
+ Args:
67
+ task_type: The type of the task.
68
+ settings: The settings of the task as submitted.
69
+ model: The name of the model, the first source of the run, empty without.
70
+
71
+ Returns:
72
+ The key; its variant is the value of the `cost_setting` of the type (the
73
+ default of the settings when the task does not set it), text as is and other
74
+ values as JSON, empty for a type without one.
75
+ """
76
+ variant = ""
77
+ if task_type.cost_setting is not None:
78
+ try:
79
+ values = task_type.settings.model_validate(settings).model_dump(mode="json")
80
+ except ValidationError:
81
+ values = dict(settings)
82
+ value = values.get(task_type.cost_setting)
83
+ variant = value if isinstance(value, str) else json.dumps(value, sort_keys=True)
84
+ return ResourceKey(task_type.name, model, variant)
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class ResolvedResources:
89
+ """The resources requested for a task.
90
+
91
+ Attributes:
92
+ memory_base_gb: Base memory in GiB.
93
+ memory_per_worker_gb: Memory per worker in GiB.
94
+ scratch_gb: Local scratch in GiB.
95
+ gpus: Number of GPUs.
96
+ seconds_per_unit: Median seconds of a unit in one process, None without
97
+ records.
98
+ samples: Number of memory observations the memory was learned from.
99
+ source: `learned from N runs`, `plugin hint`, `default` or `override`.
100
+ """
101
+
102
+ memory_base_gb: float
103
+ memory_per_worker_gb: float
104
+ scratch_gb: float
105
+ gpus: int
106
+ seconds_per_unit: float | None
107
+ samples: int
108
+ source: str
109
+
110
+
111
+ def _percentile(values: list[float], q: float) -> float:
112
+ """Returns the percentile with linear interpolation of sorted values."""
113
+ ordered = sorted(values)
114
+ pos = q * (len(ordered) - 1)
115
+ low = math.floor(pos)
116
+ high = math.ceil(pos)
117
+ return ordered[low] + (ordered[high] - ordered[low]) * (pos - low)
118
+
119
+
120
+ def _round_up(value: float) -> float:
121
+ """Rounds up to 0.1, ignoring floating point noise."""
122
+ return math.ceil(round(value * 10, 9)) / 10
123
+
124
+
125
+ def _median(values: list[float]) -> float:
126
+ return _percentile(values, 0.5)
127
+
128
+
129
+ class ResourceStore:
130
+ """Observations of the workers of past tasks in a SQLite file.
131
+
132
+ The file must be on a local volume. A connection is opened per call, so the
133
+ store is safe to use from several threads of one process.
134
+ """
135
+
136
+ def __init__(self, db: Path) -> None:
137
+ """Creates the database if needed.
138
+
139
+ Args:
140
+ db: Path of the SQLite file.
141
+ """
142
+ self.db = db
143
+ db.parent.mkdir(parents=True, exist_ok=True)
144
+ with self._connect() as con:
145
+ con.execute("PRAGMA journal_mode=WAL")
146
+ con.execute(
147
+ "CREATE TABLE IF NOT EXISTS observations ("
148
+ "key TEXT NOT NULL, per_worker_gb REAL, seconds_per_unit REAL, "
149
+ "oom INTEGER NOT NULL, created REAL NOT NULL, "
150
+ "run TEXT NOT NULL DEFAULT '')"
151
+ )
152
+ columns = {row[1] for row in con.execute("PRAGMA table_info(observations)")}
153
+ if "run" not in columns: # a store of an earlier version
154
+ con.execute(
155
+ "ALTER TABLE observations ADD COLUMN run TEXT NOT NULL DEFAULT ''"
156
+ )
157
+ con.execute(
158
+ "CREATE INDEX IF NOT EXISTS observations_key ON observations (key)"
159
+ )
160
+ con.commit()
161
+
162
+ def _connect(self) -> closing[sqlite3.Connection]:
163
+ con = sqlite3.connect(self.db, timeout=30)
164
+ return closing(con)
165
+
166
+ def record(
167
+ self,
168
+ key: ResourceKey,
169
+ report: WorkerReport,
170
+ base_gb: float,
171
+ oom: bool = False,
172
+ run: str = "",
173
+ ) -> None:
174
+ """Records the report of a worker.
175
+
176
+ Args:
177
+ key: The key of the task.
178
+ report: The report.
179
+ base_gb: The base memory of the task in GiB.
180
+ oom: Whether the worker was killed for memory (see
181
+ `simrun.service.controller.is_oom`); the limit
182
+ is used then.
183
+ run: The id of the run of the report.
184
+ """
185
+ measured = report.memory_limit_bytes if oom else report.peak_memory_bytes
186
+ per_worker = None
187
+ if measured is None:
188
+ if oom:
189
+ logger.warning("Out of memory report of %s without a memory limit", key)
190
+ else:
191
+ value = (measured / GIB - base_gb) / max(report.processes, 1)
192
+ if value > 0:
193
+ per_worker = value
194
+ # the time of a unit in one process, which an estimate multiplies by the
195
+ # rounds of the units over the processes of the pods
196
+ seconds = None
197
+ if report.units_done:
198
+ seconds = report.unit_seconds / len(report.units_done)
199
+ if per_worker is None and seconds is None:
200
+ return
201
+ with self._connect() as con:
202
+ con.execute(
203
+ "INSERT INTO observations "
204
+ "(key, per_worker_gb, seconds_per_unit, oom, created, run) "
205
+ "VALUES (?, ?, ?, ?, ?, ?)",
206
+ (key.text(), per_worker, seconds, int(oom), time.time(), run),
207
+ )
208
+ con.commit()
209
+
210
+ def resolve(
211
+ self, key: ResourceKey, hint: ResourceHint, override: ResourceOverride | None
212
+ ) -> ResolvedResources:
213
+ """Returns the resources to request.
214
+
215
+ Args:
216
+ key: The key of the task.
217
+ hint: The hint of the plugin.
218
+ override: The override of the workflow.
219
+
220
+ Returns:
221
+ The resolved resources; an override wins over learned values.
222
+ """
223
+ with self._connect() as con:
224
+ rows = con.execute(
225
+ "SELECT per_worker_gb, seconds_per_unit, oom, run FROM observations "
226
+ "WHERE key = ? ORDER BY rowid DESC LIMIT ?",
227
+ (key.text(), WINDOW),
228
+ ).fetchall()
229
+ rows.reverse() # oldest first
230
+ measured = [r[0] for r in rows if r[0] is not None and not r[2]]
231
+ seconds = [r[1] for r in rows if r[1] is not None]
232
+ # a kill counts until a later run of the key succeeded
233
+ ooms = [
234
+ r[0]
235
+ for i, r in enumerate(rows)
236
+ if r[0] is not None
237
+ and r[2]
238
+ and not any(not later[2] and later[3] != r[3] for later in rows[i + 1 :])
239
+ ]
240
+ if measured or ooms:
241
+ wanted = _percentile(measured, PERCENTILE) * MARGIN if measured else 0.0
242
+ if ooms:
243
+ wanted = max(wanted, max(ooms) * RETRY_FACTOR)
244
+ per_worker = max(MIN_PER_WORKER_GB, _round_up(wanted))
245
+ source = f"learned from {len(measured) + len(ooms)} runs"
246
+ elif hint.memory_per_worker_gb != DEFAULT_PER_WORKER_GB:
247
+ per_worker = hint.memory_per_worker_gb
248
+ source = "plugin hint"
249
+ else:
250
+ per_worker = DEFAULT_PER_WORKER_GB
251
+ source = "default"
252
+ if override is not None and override.memory_per_worker_gb is not None:
253
+ per_worker = override.memory_per_worker_gb
254
+ source = "override"
255
+ scratch = hint.scratch_gb
256
+ gpus = hint.gpus
257
+ if override is not None:
258
+ if override.scratch_gb is not None:
259
+ scratch = override.scratch_gb
260
+ if override.gpus is not None:
261
+ gpus = override.gpus
262
+ return ResolvedResources(
263
+ memory_base_gb=hint.memory_base_gb,
264
+ memory_per_worker_gb=per_worker,
265
+ scratch_gb=scratch,
266
+ gpus=gpus,
267
+ seconds_per_unit=_median(seconds) if seconds else None,
268
+ samples=len(measured) + len(ooms),
269
+ source=source,
270
+ )
@@ -0,0 +1 @@
1
+ """The service: an API to submit and follow runs, and the controller behind it."""