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/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."""
|