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/mcp.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""The MCP server of simrun, a local stdio process of an agent.
|
|
2
|
+
|
|
3
|
+
The tools go through the `Client`; they return the JSON of the API and `store`, the
|
|
4
|
+
absolute local prefix of the paths in it, which are relative to the store, so an
|
|
5
|
+
agent opens a result with `store / path`. A refusal is returned as
|
|
6
|
+
`{"error": detail, "status": code}` instead of raised, so an agent can read it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import subprocess
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from fastmcp import FastMCP
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
|
|
19
|
+
from simrun.client import Client, ClientError
|
|
20
|
+
from simrun.service.logs import DEFAULT_TAIL_LINES
|
|
21
|
+
from simrun.store import RunState
|
|
22
|
+
from simrun.workflow import SplitMode
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
DEFAULT_WAIT_S = 600.0
|
|
27
|
+
# status of an error which did not come from the service
|
|
28
|
+
CLIENT_SIDE = 400
|
|
29
|
+
UNREACHABLE = 503
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _plain(value: Any) -> Any:
|
|
33
|
+
if isinstance(value, BaseModel):
|
|
34
|
+
return value.model_dump(mode="json")
|
|
35
|
+
if isinstance(value, dict):
|
|
36
|
+
return {k: _plain(v) for k, v in value.items()}
|
|
37
|
+
if isinstance(value, list):
|
|
38
|
+
return [_plain(v) for v in value]
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_server(client: Client) -> FastMCP:
|
|
43
|
+
"""The MCP server whose tools call the service through `client`."""
|
|
44
|
+
server = FastMCP("simrun")
|
|
45
|
+
store = str(client.store.resolve())
|
|
46
|
+
|
|
47
|
+
def answer(call: Callable[[], dict[str, Any]]) -> dict[str, Any]:
|
|
48
|
+
try:
|
|
49
|
+
return {**_plain(call()), "store": store}
|
|
50
|
+
except ClientError as error:
|
|
51
|
+
return {"error": error.detail, "status": error.status}
|
|
52
|
+
except httpx.TransportError as error:
|
|
53
|
+
return {
|
|
54
|
+
"error": f"the service is not reachable: {error}",
|
|
55
|
+
"status": UNREACHABLE,
|
|
56
|
+
}
|
|
57
|
+
except (ValueError, RuntimeError, OSError, subprocess.SubprocessError) as error:
|
|
58
|
+
return {"error": str(error), "status": CLIENT_SIDE}
|
|
59
|
+
|
|
60
|
+
@server.tool
|
|
61
|
+
def submit_workflow(
|
|
62
|
+
workflow_path: str,
|
|
63
|
+
sources: dict[str, str] | None = None,
|
|
64
|
+
group: str | None = None,
|
|
65
|
+
single_server: bool = False,
|
|
66
|
+
) -> dict[str, Any]:
|
|
67
|
+
"""Snapshot the working trees and submit a workflow.
|
|
68
|
+
|
|
69
|
+
`sources` maps a source name to the path of its git working tree, the model
|
|
70
|
+
first. Returns the summary of the run at once, in the state `submitting`
|
|
71
|
+
while its environment is resolved and built; follow it with `wait_for_run`.
|
|
72
|
+
"""
|
|
73
|
+
paths = {name: Path(p).expanduser() for name, p in (sources or {}).items()}
|
|
74
|
+
split = SplitMode.SINGLE_SERVER if single_server else None
|
|
75
|
+
return answer(
|
|
76
|
+
lambda: {
|
|
77
|
+
"run": client.submit(
|
|
78
|
+
Path(workflow_path).expanduser(), paths, group, split
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@server.tool
|
|
84
|
+
def wait_for_run(run_id: str, timeout_s: float = DEFAULT_WAIT_S) -> dict[str, Any]:
|
|
85
|
+
"""Wait for a run to end or for `timeout_s` seconds; returns its summary."""
|
|
86
|
+
return answer(lambda: {"run": client.wait(run_id, timeout_s)})
|
|
87
|
+
|
|
88
|
+
@server.tool
|
|
89
|
+
def run_status(run_id: str) -> dict[str, Any]:
|
|
90
|
+
"""The record of a run: state, tasks, plans and the provenance of its sources."""
|
|
91
|
+
return answer(lambda: {"run": client.status(run_id)})
|
|
92
|
+
|
|
93
|
+
@server.tool
|
|
94
|
+
def run_progress(run_id: str) -> dict[str, Any]:
|
|
95
|
+
"""The progress of every task of a run: ledger counts, first and last progress lines."""
|
|
96
|
+
return answer(lambda: {"progress": client.progress(run_id)})
|
|
97
|
+
|
|
98
|
+
@server.tool
|
|
99
|
+
def list_runs(
|
|
100
|
+
group: str | None = None,
|
|
101
|
+
agent: str | None = None,
|
|
102
|
+
state: str | None = None,
|
|
103
|
+
owner: str | None = None,
|
|
104
|
+
) -> dict[str, Any]:
|
|
105
|
+
"""The runs, filtered by group, agent, state and owner.
|
|
106
|
+
|
|
107
|
+
The runs of an owner include the ones of their agents.
|
|
108
|
+
"""
|
|
109
|
+
return answer(
|
|
110
|
+
lambda: {
|
|
111
|
+
"runs": client.list_runs(
|
|
112
|
+
group, agent, RunState(state) if state else None, owner
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
@server.tool
|
|
118
|
+
def compare_runs(run_ids: list[str]) -> dict[str, Any]:
|
|
119
|
+
"""Provenance and task summaries of runs side by side."""
|
|
120
|
+
return answer(lambda: {"table": client.compare(run_ids)})
|
|
121
|
+
|
|
122
|
+
@server.tool
|
|
123
|
+
def cancel_run(run_id: str) -> dict[str, Any]:
|
|
124
|
+
"""Cancel a run."""
|
|
125
|
+
return answer(lambda: {"run": client.cancel(run_id)})
|
|
126
|
+
|
|
127
|
+
@server.tool
|
|
128
|
+
def run_logs(
|
|
129
|
+
run_id: str, task_id: str, tail_lines: int = DEFAULT_TAIL_LINES
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
"""The logs of the pods of a task, the last `tail_lines` of each (0: all)."""
|
|
132
|
+
return answer(lambda: {"logs": client.logs(run_id, task_id, tail_lines)})
|
|
133
|
+
|
|
134
|
+
@server.tool
|
|
135
|
+
def run_outputs(run_id: str, task_id: str) -> dict[str, Any]:
|
|
136
|
+
"""The output files of a task, paths relative to `store`."""
|
|
137
|
+
return answer(lambda: {"files": client.outputs(run_id, task_id)})
|
|
138
|
+
|
|
139
|
+
@server.tool
|
|
140
|
+
def estimate_workflow(
|
|
141
|
+
workflow_path: str,
|
|
142
|
+
sources: dict[str, str] | None = None,
|
|
143
|
+
single_server: bool = False,
|
|
144
|
+
) -> dict[str, Any]:
|
|
145
|
+
"""The plan and the expected duration of every task of a submission."""
|
|
146
|
+
paths = {name: Path(p).expanduser() for name, p in (sources or {}).items()}
|
|
147
|
+
split = SplitMode.SINGLE_SERVER if single_server else None
|
|
148
|
+
return answer(
|
|
149
|
+
lambda: {
|
|
150
|
+
"tasks": client.estimate(
|
|
151
|
+
Path(workflow_path).expanduser(), paths, None, split
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return server
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def main() -> None:
|
|
160
|
+
"""Run the server on stdio with the client of the environment."""
|
|
161
|
+
build_server(Client.from_env()).run()
|
simrun/planner.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""The planner: the shape of a worker pod and the number of pieces of a task."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import math
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from simrun.plugins import TaskType
|
|
9
|
+
from simrun.plugins_builtin import COMMAND, CommandSettings
|
|
10
|
+
from simrun.resources import GIB, RETRY_FACTOR, ResolvedResources
|
|
11
|
+
from simrun.worker import processes
|
|
12
|
+
from simrun.workflow import Priority, SplitMode, TaskSpec
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
MAX_PIECES = 8
|
|
17
|
+
PRIORITY_VALUES = {
|
|
18
|
+
"simrun-owner-base": 1000,
|
|
19
|
+
"simrun-owner-extra": 900,
|
|
20
|
+
"simrun-standard-base": 500,
|
|
21
|
+
"simrun-standard-extra": 400,
|
|
22
|
+
"simrun-agent-base": 200,
|
|
23
|
+
"simrun-agent-extra": 100,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PlanError(ValueError):
|
|
28
|
+
"""A task which cannot be planned on the nodes."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, init=False)
|
|
32
|
+
class Node:
|
|
33
|
+
"""A node of the cluster.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
name: Name of the node.
|
|
37
|
+
cores: The whole cores a worker pod of a run can have on the node, what the
|
|
38
|
+
planner plans with.
|
|
39
|
+
memory_gb: The GiB of memory a worker pod of a run can have on the node,
|
|
40
|
+
what the planner plans with.
|
|
41
|
+
gpus: Number of GPUs.
|
|
42
|
+
reserved_by: Name of the reservation holding the node, None if free.
|
|
43
|
+
allocatable_cores: The cores the scheduler gives the pods of the runs
|
|
44
|
+
(sidecars included), as it counts their requests.
|
|
45
|
+
allocatable_memory_gb: The GiB of memory the scheduler gives the pods of
|
|
46
|
+
the runs, as it counts their requests.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
cores: int
|
|
51
|
+
memory_gb: float
|
|
52
|
+
gpus: int
|
|
53
|
+
reserved_by: str | None
|
|
54
|
+
allocatable_cores: float
|
|
55
|
+
allocatable_memory_gb: float
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
name: str,
|
|
60
|
+
cores: int,
|
|
61
|
+
memory_gb: float,
|
|
62
|
+
gpus: int = 0,
|
|
63
|
+
reserved_by: str | None = None,
|
|
64
|
+
allocatable_cores: float | None = None,
|
|
65
|
+
allocatable_memory_gb: float | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Create the node; the allocatable cores and memory default to the others."""
|
|
68
|
+
values = {
|
|
69
|
+
"name": name,
|
|
70
|
+
"cores": cores,
|
|
71
|
+
"memory_gb": memory_gb,
|
|
72
|
+
"gpus": gpus,
|
|
73
|
+
"reserved_by": reserved_by,
|
|
74
|
+
"allocatable_cores": float(
|
|
75
|
+
cores if allocatable_cores is None else allocatable_cores
|
|
76
|
+
),
|
|
77
|
+
"allocatable_memory_gb": float(
|
|
78
|
+
memory_gb if allocatable_memory_gb is None else allocatable_memory_gb
|
|
79
|
+
),
|
|
80
|
+
}
|
|
81
|
+
for key, value in values.items():
|
|
82
|
+
# a frozen dataclass sets its fields past its own __setattr__
|
|
83
|
+
object.__setattr__(self, key, value)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class PodShape:
|
|
88
|
+
"""The resources of a worker pod.
|
|
89
|
+
|
|
90
|
+
Attributes:
|
|
91
|
+
cores: Number of cores.
|
|
92
|
+
memory_gb: Memory in GiB, rounded up.
|
|
93
|
+
processes: Number of worker processes.
|
|
94
|
+
per_worker_gb: Memory per worker process in GiB.
|
|
95
|
+
retry_memory_gb: Memory of a retry in GiB.
|
|
96
|
+
retry_per_worker_gb: Memory per worker process of a retry in GiB.
|
|
97
|
+
scratch_gb: Local scratch in GiB.
|
|
98
|
+
gpus: Number of GPUs.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
cores: int
|
|
102
|
+
memory_gb: int
|
|
103
|
+
processes: int
|
|
104
|
+
per_worker_gb: float
|
|
105
|
+
retry_memory_gb: int
|
|
106
|
+
retry_per_worker_gb: float
|
|
107
|
+
scratch_gb: float
|
|
108
|
+
gpus: int
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class TaskPlan:
|
|
113
|
+
"""The plan of a task.
|
|
114
|
+
|
|
115
|
+
Attributes:
|
|
116
|
+
task_id: Id of the task.
|
|
117
|
+
pods: Number of worker pods.
|
|
118
|
+
shape: Shape of a pod.
|
|
119
|
+
base_priority: Priority class of the first pods.
|
|
120
|
+
extra_priority: Priority class of the further pods.
|
|
121
|
+
units: Number of units, None for an unsplit task.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
task_id: str
|
|
125
|
+
pods: int
|
|
126
|
+
shape: PodShape
|
|
127
|
+
base_priority: str
|
|
128
|
+
extra_priority: str
|
|
129
|
+
units: int | None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _usable(nodes: list[Node], gpus: int) -> list[Node]:
|
|
133
|
+
return [n for n in nodes if n.reserved_by is None and n.gpus >= gpus]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _common_node(nodes: list[Node], gpus: int) -> Node:
|
|
137
|
+
"""Returns the most common node by cores and memory, the larger on ties."""
|
|
138
|
+
usable = _usable(nodes, gpus)
|
|
139
|
+
if not usable:
|
|
140
|
+
raise PlanError(f"no node which is not reserved has {gpus} GPUs")
|
|
141
|
+
counts = Counter((n.cores, n.memory_gb) for n in usable)
|
|
142
|
+
cores, memory = max(counts, key=lambda k: (counts[k], k[1], k[0]))
|
|
143
|
+
return next(n for n in usable if (n.cores, n.memory_gb) == (cores, memory))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _largest(nodes: list[Node], gpus: int) -> Node:
|
|
147
|
+
usable = _usable(nodes, gpus)
|
|
148
|
+
if not usable:
|
|
149
|
+
raise PlanError(f"no node which is not reserved has {gpus} GPUs")
|
|
150
|
+
return max(usable, key=lambda n: (n.memory_gb, n.cores))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _fit(node: Node, base: float, per_worker: float) -> int | None:
|
|
154
|
+
"""Returns the number of processes on the node, None if one does not fit."""
|
|
155
|
+
try:
|
|
156
|
+
return processes(node.cores, int(node.memory_gb * GIB), base, per_worker)
|
|
157
|
+
except ValueError:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def shape(
|
|
162
|
+
resources: ResolvedResources, nodes: list[Node], max_processes: int | None = None
|
|
163
|
+
) -> PodShape:
|
|
164
|
+
"""Sizes a pod for the most common node which is not reserved.
|
|
165
|
+
|
|
166
|
+
When one worker does not fit that node, the largest usable node is taken.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
resources: The resources of the task.
|
|
170
|
+
nodes: The nodes of the cluster.
|
|
171
|
+
max_processes: At most this many processes, as many as fit by default.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The shape of a pod.
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
PlanError: One worker fits on no node.
|
|
178
|
+
"""
|
|
179
|
+
base = resources.memory_base_gb
|
|
180
|
+
per_worker = resources.memory_per_worker_gb
|
|
181
|
+
node = _common_node(nodes, resources.gpus)
|
|
182
|
+
n = _fit(node, base, per_worker)
|
|
183
|
+
if n is None:
|
|
184
|
+
node = _largest(nodes, resources.gpus)
|
|
185
|
+
n = _fit(node, base, per_worker)
|
|
186
|
+
if n is None:
|
|
187
|
+
raise PlanError(
|
|
188
|
+
f"one worker needs {per_worker} GiB besides {base} GiB base, more than "
|
|
189
|
+
f"the largest node {node.name} with {node.memory_gb} GiB"
|
|
190
|
+
)
|
|
191
|
+
if max_processes is not None:
|
|
192
|
+
n = min(n, max_processes)
|
|
193
|
+
retry_per_worker = per_worker * RETRY_FACTOR
|
|
194
|
+
retry_node = node
|
|
195
|
+
retry_n = _fit(retry_node, base, retry_per_worker)
|
|
196
|
+
if retry_n is None:
|
|
197
|
+
retry_node = _largest(nodes, resources.gpus)
|
|
198
|
+
retry_n = _fit(retry_node, base, retry_per_worker)
|
|
199
|
+
if retry_n is not None:
|
|
200
|
+
retry_n = min(retry_n, n)
|
|
201
|
+
retry_memory = math.ceil(base + retry_per_worker * retry_n)
|
|
202
|
+
else:
|
|
203
|
+
logger.warning(
|
|
204
|
+
"a retry with %s GiB per worker fits on no node, "
|
|
205
|
+
"it takes the whole memory of %s with one process",
|
|
206
|
+
retry_per_worker,
|
|
207
|
+
retry_node.name,
|
|
208
|
+
)
|
|
209
|
+
retry_memory = int(retry_node.memory_gb)
|
|
210
|
+
retry_per_worker = max(0.0, retry_memory - base)
|
|
211
|
+
return PodShape(
|
|
212
|
+
cores=n,
|
|
213
|
+
memory_gb=math.ceil(base + per_worker * n),
|
|
214
|
+
processes=n,
|
|
215
|
+
per_worker_gb=per_worker,
|
|
216
|
+
retry_memory_gb=retry_memory,
|
|
217
|
+
retry_per_worker_gb=retry_per_worker,
|
|
218
|
+
scratch_gb=resources.scratch_gb,
|
|
219
|
+
gpus=resources.gpus,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _command_shape(settings: CommandSettings, nodes: list[Node]) -> PodShape:
|
|
224
|
+
memory = math.ceil(settings.memory_gb)
|
|
225
|
+
fitting = [
|
|
226
|
+
n
|
|
227
|
+
for n in _usable(nodes, settings.gpus)
|
|
228
|
+
if n.cores >= settings.cores and n.memory_gb >= memory
|
|
229
|
+
]
|
|
230
|
+
if not fitting:
|
|
231
|
+
largest = _largest(nodes, settings.gpus)
|
|
232
|
+
raise PlanError(
|
|
233
|
+
f"the command needs {settings.cores} cores, {memory} GiB and "
|
|
234
|
+
f"{settings.gpus} GPUs, more than the largest node {largest.name} "
|
|
235
|
+
f"with {largest.cores} cores and {largest.memory_gb} GiB"
|
|
236
|
+
)
|
|
237
|
+
node_memory = max(n.memory_gb for n in fitting)
|
|
238
|
+
retry = max(
|
|
239
|
+
memory, min(math.ceil(settings.memory_gb * RETRY_FACTOR), int(node_memory))
|
|
240
|
+
)
|
|
241
|
+
return PodShape(
|
|
242
|
+
cores=settings.cores,
|
|
243
|
+
memory_gb=memory,
|
|
244
|
+
processes=1,
|
|
245
|
+
per_worker_gb=settings.memory_gb,
|
|
246
|
+
retry_memory_gb=retry,
|
|
247
|
+
# processes is 1, so the memory per worker of a retry is its memory
|
|
248
|
+
retry_per_worker_gb=float(retry),
|
|
249
|
+
scratch_gb=settings.scratch_gb,
|
|
250
|
+
gpus=settings.gpus,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _slots(pod: PodShape, nodes: list[Node]) -> int:
|
|
255
|
+
total = 0
|
|
256
|
+
for n in _usable(nodes, pod.gpus):
|
|
257
|
+
fit = min(int(n.cores // pod.cores), int(n.memory_gb // pod.memory_gb))
|
|
258
|
+
if pod.gpus:
|
|
259
|
+
fit = min(fit, n.gpus // pod.gpus)
|
|
260
|
+
total += max(fit, 0)
|
|
261
|
+
return total
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def plan_task(
|
|
265
|
+
task: TaskSpec,
|
|
266
|
+
task_type: TaskType,
|
|
267
|
+
resources: ResolvedResources,
|
|
268
|
+
nodes: list[Node],
|
|
269
|
+
mode: SplitMode,
|
|
270
|
+
priority: Priority,
|
|
271
|
+
units: int | None,
|
|
272
|
+
) -> TaskPlan:
|
|
273
|
+
"""Plans the pods of a task.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
task: The task.
|
|
277
|
+
task_type: Its type.
|
|
278
|
+
resources: The resolved resources.
|
|
279
|
+
nodes: The nodes of the cluster.
|
|
280
|
+
mode: The split mode.
|
|
281
|
+
priority: The priority of the run.
|
|
282
|
+
units: The number of units, None for an unsplit task.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
The plan; extra pods are not limited by idle capacity, Kueue queues them.
|
|
286
|
+
|
|
287
|
+
Raises:
|
|
288
|
+
PlanError: The task fits on no node.
|
|
289
|
+
"""
|
|
290
|
+
base = f"simrun-{priority.value}-base"
|
|
291
|
+
extra = f"simrun-{priority.value}-extra"
|
|
292
|
+
if not task_type.splittable:
|
|
293
|
+
if task_type.name == COMMAND.name:
|
|
294
|
+
pod = _command_shape(CommandSettings.model_validate(task.settings), nodes)
|
|
295
|
+
else:
|
|
296
|
+
# a single command is one process, unless it uses the cores of its pod
|
|
297
|
+
pod = shape(resources, nodes, None if task_type.uses_cores else 1)
|
|
298
|
+
return TaskPlan(task.id, 1, pod, base, extra, None)
|
|
299
|
+
pod = shape(resources, nodes)
|
|
300
|
+
pods = 1
|
|
301
|
+
if mode == SplitMode.FASTEST and units is not None:
|
|
302
|
+
pods = min(MAX_PIECES, units, _slots(pod, nodes))
|
|
303
|
+
return TaskPlan(task.id, max(1, pods), pod, base, extra, units)
|
simrun/plugins.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""The plugin interface: task types, their resource hints and their discovery."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from importlib import import_module
|
|
6
|
+
from importlib.metadata import entry_points
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
ENTRY_POINT_GROUP = "simrun.task_types"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ResourceHint:
|
|
17
|
+
"""Default resources of a task type.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
memory_base_gb: Base memory of a task in GiB.
|
|
21
|
+
memory_per_worker_gb: Memory per worker in GiB.
|
|
22
|
+
scratch_gb: Local scratch in GiB.
|
|
23
|
+
gpus: Number of GPUs.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
memory_base_gb: float = 2.0
|
|
27
|
+
memory_per_worker_gb: float = 6.0
|
|
28
|
+
scratch_gb: float = 0.0
|
|
29
|
+
gpus: int = 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class TaskPaths:
|
|
34
|
+
"""The locations a task works in.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
store: Root of the run store.
|
|
38
|
+
run_dir: Directory of the run.
|
|
39
|
+
task_dir: Directory of the task.
|
|
40
|
+
out: Output directory of the task, `task_dir / "out"`.
|
|
41
|
+
inputs: Input port to the directory of the output it reads.
|
|
42
|
+
scratch: Local scratch directory.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
store: Path
|
|
46
|
+
run_dir: Path
|
|
47
|
+
task_dir: Path
|
|
48
|
+
out: Path
|
|
49
|
+
inputs: dict[str, Path]
|
|
50
|
+
scratch: Path
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class TaskType:
|
|
55
|
+
"""A type of task, registered as an entry point of `ENTRY_POINT_GROUP`.
|
|
56
|
+
|
|
57
|
+
The callables are `"module:function"` strings, so a worker process resolves
|
|
58
|
+
them by import, see `resolve`. Signatures: `count_units(settings) -> int`,
|
|
59
|
+
`setup(settings, paths) -> None` (once per process),
|
|
60
|
+
`compute_unit(settings, index, paths) -> None`, `merge(settings, paths) -> None`
|
|
61
|
+
and `plan(settings) -> settings`, which the client calls before a submission
|
|
62
|
+
when the type is installed locally (`simrun.client.plan_tasks`).
|
|
63
|
+
|
|
64
|
+
A unit may be computed more than once: by a worker which is evicted before it
|
|
65
|
+
completes the unit, or by a restarted incarnation of a worker whose earlier one
|
|
66
|
+
still runs. So `compute_unit` writes outputs which depend on the settings and the
|
|
67
|
+
index only (deterministic), each written atomically (a temporary file in the same
|
|
68
|
+
directory, then renamed), so a reader never sees a partial unit.
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
name: Name of the type, as used in a workflow.
|
|
72
|
+
settings: Pydantic model of the settings.
|
|
73
|
+
inputs: Names of the input ports.
|
|
74
|
+
outputs: Names of the output ports.
|
|
75
|
+
hint: Default resources.
|
|
76
|
+
checkpoints: Whether the task resumes from checkpoints.
|
|
77
|
+
command: Argv template of unsplit tasks with `{settings}`, `{out}` and
|
|
78
|
+
`{input}`: a part with `{input}` is repeated once per input port of the
|
|
79
|
+
task, sorted by port, with `<port>=<path>` (`<scratch>/<port>` for a
|
|
80
|
+
staged port), and dropped without inputs.
|
|
81
|
+
It runs through `simrun-exec` (see `simrun.worker.exec_main`).
|
|
82
|
+
uses_cores: Whether the command of an unsplit task uses the cores of its pod
|
|
83
|
+
(a pool of processes); else the pod is sized for one process.
|
|
84
|
+
count_units: Reference to the function counting the units of work.
|
|
85
|
+
setup: Reference to the function preparing a process.
|
|
86
|
+
compute_unit: Reference to the function computing one unit.
|
|
87
|
+
merge: Reference to the function merging the units.
|
|
88
|
+
plan: Reference to the function which completes the settings of a task
|
|
89
|
+
before it is submitted, e.g. with values derived from its model.
|
|
90
|
+
image: Image of the task, None for the environment image of the run.
|
|
91
|
+
cost_setting: Name of the setting which changes the cost of a task, e.g. the
|
|
92
|
+
subset of the data of a fit; the resources are learned per value of it
|
|
93
|
+
(`simrun.resources.resource_key`).
|
|
94
|
+
description: What a task of the type does in a few words, the subtitle of
|
|
95
|
+
the type in the palette of the Editor.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
name: str
|
|
99
|
+
settings: type[BaseModel]
|
|
100
|
+
inputs: tuple[str, ...] = ()
|
|
101
|
+
outputs: tuple[str, ...] = ("out",)
|
|
102
|
+
hint: ResourceHint = ResourceHint()
|
|
103
|
+
checkpoints: bool = False
|
|
104
|
+
command: str | None = None
|
|
105
|
+
uses_cores: bool = False
|
|
106
|
+
count_units: str | None = None
|
|
107
|
+
setup: str | None = None
|
|
108
|
+
compute_unit: str | None = None
|
|
109
|
+
merge: str | None = None
|
|
110
|
+
plan: str | None = None
|
|
111
|
+
image: str | None = None
|
|
112
|
+
cost_setting: str | None = None
|
|
113
|
+
description: str = ""
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def splittable(self) -> bool:
|
|
117
|
+
"""Whether the task can be split into pieces."""
|
|
118
|
+
return None not in (self.count_units, self.compute_unit, self.merge)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def resolve(ref: str) -> Callable[..., Any]:
|
|
122
|
+
"""Import the function a `"module:function"` string names.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
ref: Reference of the form `module:function`.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
The function.
|
|
129
|
+
"""
|
|
130
|
+
module, _, name = ref.partition(":")
|
|
131
|
+
return getattr(import_module(module), name)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def load_task_types() -> dict[str, TaskType]:
|
|
135
|
+
"""Load the task types of all entry points.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
The task types by name.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
ValueError: A name is registered twice.
|
|
142
|
+
"""
|
|
143
|
+
types: dict[str, TaskType] = {}
|
|
144
|
+
for entry in entry_points(group=ENTRY_POINT_GROUP):
|
|
145
|
+
task_type = entry.load()
|
|
146
|
+
if task_type.name in types:
|
|
147
|
+
raise ValueError(f"duplicate task type {task_type.name!r}")
|
|
148
|
+
types[task_type.name] = task_type
|
|
149
|
+
return types
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""The built-in task types."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from simrun.plugins import TaskType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CommandSettings(BaseModel):
|
|
9
|
+
"""Settings of a plain command task.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
image: Image the command runs in.
|
|
13
|
+
args: Argument vector of the command.
|
|
14
|
+
cores: Number of cores.
|
|
15
|
+
memory_gb: Memory in GiB.
|
|
16
|
+
gpus: Number of GPUs.
|
|
17
|
+
scratch_gb: Local scratch in GiB.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
image: str
|
|
21
|
+
args: list[str]
|
|
22
|
+
cores: int
|
|
23
|
+
memory_gb: float
|
|
24
|
+
gpus: int = 0
|
|
25
|
+
scratch_gb: float = 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# `command` is None: the settings carry the image and the args.
|
|
29
|
+
COMMAND = TaskType(
|
|
30
|
+
name="command",
|
|
31
|
+
settings=CommandSettings,
|
|
32
|
+
description="A command in the image of the run",
|
|
33
|
+
)
|