manifold-cli 0.0.39__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.
- manifold_cli/__init__.py +3 -0
- manifold_cli/__main__.py +4 -0
- manifold_cli/bridge.py +70 -0
- manifold_cli/cli.py +54 -0
- manifold_cli/clients/__init__.py +0 -0
- manifold_cli/clients/auth_flow.py +169 -0
- manifold_cli/clients/credentials.py +53 -0
- manifold_cli/clients/docker_engine.py +43 -0
- manifold_cli/clients/image.py +130 -0
- manifold_cli/clients/manifold.py +1288 -0
- manifold_cli/clients/runner_home.py +165 -0
- manifold_cli/clients/workos.py +99 -0
- manifold_cli/commands/__init__.py +0 -0
- manifold_cli/commands/auth.py +101 -0
- manifold_cli/commands/benchmark.py +539 -0
- manifold_cli/commands/organization.py +154 -0
- manifold_cli/commands/policy.py +749 -0
- manifold_cli/commands/run.py +337 -0
- manifold_cli/commands/run_log.py +291 -0
- manifold_cli/commands/runner.py +852 -0
- manifold_cli/commands/table.py +101 -0
- manifold_cli/commands/token.py +144 -0
- manifold_cli/dependencies.py +116 -0
- manifold_cli/models/__init__.py +0 -0
- manifold_cli/models/auth.py +49 -0
- manifold_cli/models/benchmark.py +87 -0
- manifold_cli/models/catalog.py +189 -0
- manifold_cli/models/credentials.py +19 -0
- manifold_cli/models/errors.py +121 -0
- manifold_cli/models/log.py +114 -0
- manifold_cli/models/observability.py +121 -0
- manifold_cli/models/organization.py +19 -0
- manifold_cli/models/pagination.py +59 -0
- manifold_cli/models/policy.py +49 -0
- manifold_cli/models/run.py +381 -0
- manifold_cli/models/runner.py +186 -0
- manifold_cli/models/runner_home.py +69 -0
- manifold_cli/models/task_queue.py +40 -0
- manifold_cli/models/token.py +39 -0
- manifold_cli/replay/__init__.py +68 -0
- manifold_cli/replay/log.py +814 -0
- manifold_cli/runner/__init__.py +72 -0
- manifold_cli/runner/api_retry.py +36 -0
- manifold_cli/runner/benchmark/__init__.py +800 -0
- manifold_cli/runner/benchmark/live_view.py +248 -0
- manifold_cli/runner/benchmark/preassigned_scheduling.py +325 -0
- manifold_cli/runner/benchmark/recording.py +1208 -0
- manifold_cli/runner/benchmark/scheduling.py +256 -0
- manifold_cli/runner/benchmark/task_queue_scheduling.py +717 -0
- manifold_cli/runner/benchmark/task_server.py +123 -0
- manifold_cli/runner/benchmark/video.py +124 -0
- manifold_cli/runner/benchmark_steps.py +178 -0
- manifold_cli/runner/cancellation.py +22 -0
- manifold_cli/runner/capture.py +282 -0
- manifold_cli/runner/container.py +370 -0
- manifold_cli/runner/current_dispatch.py +25 -0
- manifold_cli/runner/daemon.py +520 -0
- manifold_cli/runner/dispatch.py +38 -0
- manifold_cli/runner/gpu.py +182 -0
- manifold_cli/runner/heartbeat.py +202 -0
- manifold_cli/runner/loopback.py +102 -0
- manifold_cli/runner/loopback_bench.py +183 -0
- manifold_cli/runner/loopback_policy.py +39 -0
- manifold_cli/runner/output.py +218 -0
- manifold_cli/runner/pairing_acceptance.py +57 -0
- manifold_cli/runner/plan.py +369 -0
- manifold_cli/runner/policy.py +350 -0
- manifold_cli/runner/policy_steps.py +164 -0
- manifold_cli/runner/preparation.py +47 -0
- manifold_cli/runner/process.py +219 -0
- manifold_cli/runner/ram.py +17 -0
- manifold_cli/runner/resource_usage.py +265 -0
- manifold_cli/runner/served.py +218 -0
- manifold_cli/runner/service.py +120 -0
- manifold_cli/runner/side_failure.py +68 -0
- manifold_cli/runner/signature.py +98 -0
- manifold_cli/runner/source.py +66 -0
- manifold_cli/runner/stages.py +241 -0
- manifold_cli/runner/step_timing.py +229 -0
- manifold_cli/runner/worker.py +534 -0
- manifold_cli/runner/workload.py +401 -0
- manifold_cli/services/__init__.py +0 -0
- manifold_cli/services/auth.py +89 -0
- manifold_cli/services/benchmark.py +357 -0
- manifold_cli/services/organization.py +22 -0
- manifold_cli/services/policy.py +234 -0
- manifold_cli/services/run.py +304 -0
- manifold_cli/services/runner.py +207 -0
- manifold_cli/services/runner_daemon.py +146 -0
- manifold_cli/services/token.py +52 -0
- manifold_cli/settings.py +30 -0
- manifold_cli/transport/__init__.py +31 -0
- manifold_cli/transport/diagnostics.py +72 -0
- manifold_cli/transport/interfaces.py +69 -0
- manifold_cli/transport/iroh.py +317 -0
- manifold_cli/transport/registry.py +34 -0
- manifold_cli/transport/tcp.py +146 -0
- manifold_cli-0.0.39.dist-info/METADATA +406 -0
- manifold_cli-0.0.39.dist-info/RECORD +102 -0
- manifold_cli-0.0.39.dist-info/WHEEL +4 -0
- manifold_cli-0.0.39.dist-info/entry_points.txt +2 -0
- manifold_cli-0.0.39.dist-info/licenses/LICENSE +373 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""GPU and driver detection for runner placement, and GPU usage sampling."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
from manifold_cli.models.observability import GpuUsage
|
|
7
|
+
from manifold_cli.models.runner import RunnerGpuDevice, RunnerGpuFacts
|
|
8
|
+
|
|
9
|
+
MEBIBYTES_PER_GIBIBYTE = 1024
|
|
10
|
+
BYTES_PER_MEBIBYTE = 1024**2
|
|
11
|
+
# `nvidia-smi` prints a value it cannot read in brackets, such as `[N/A]` or
|
|
12
|
+
# `[Not Supported]`.
|
|
13
|
+
UNREADABLE_VALUE_PREFIX = "["
|
|
14
|
+
# A driver module still loading blocks `nvidia-smi` rather than failing it.
|
|
15
|
+
NVIDIA_SMI_TIMEOUT_SECONDS = 10.0
|
|
16
|
+
# The `nvidia-smi --query-gpu` fields of `RunnerGpuDevice` past its index,
|
|
17
|
+
# name and memory, in the order that `_parse_gpu_device` unpacks them. A
|
|
18
|
+
# driver that does not know one of them refuses the whole query, so
|
|
19
|
+
# `gpu_facts` asks for them in a second query.
|
|
20
|
+
GPU_DETAIL_FIELDS = (
|
|
21
|
+
"index",
|
|
22
|
+
"compute_cap",
|
|
23
|
+
"mig.mode.current",
|
|
24
|
+
"ecc.mode.current",
|
|
25
|
+
"compute_mode",
|
|
26
|
+
"power.limit",
|
|
27
|
+
"clocks.max.sm",
|
|
28
|
+
"pcie.link.gen.max",
|
|
29
|
+
"pcie.link.width.max",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def gpu_facts() -> RunnerGpuFacts:
|
|
34
|
+
"""Return the visible GPU facts, or empty facts when the probe fails.
|
|
35
|
+
|
|
36
|
+
A GPU's details are None when the driver cannot report them.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
rows = _query_gpus(("index", "name", "memory.total", "driver_version"))
|
|
40
|
+
details = _gpu_details()
|
|
41
|
+
devices = [
|
|
42
|
+
_parse_gpu_device(index, name, memory_mib, details.get(index))
|
|
43
|
+
for index, name, memory_mib, _driver_version in rows
|
|
44
|
+
]
|
|
45
|
+
driver_version = next((driver for *_, driver in rows), None)
|
|
46
|
+
return RunnerGpuFacts(devices=devices, driver_version=driver_version)
|
|
47
|
+
except (
|
|
48
|
+
FileNotFoundError,
|
|
49
|
+
subprocess.CalledProcessError,
|
|
50
|
+
subprocess.TimeoutExpired,
|
|
51
|
+
ValueError,
|
|
52
|
+
):
|
|
53
|
+
return RunnerGpuFacts(devices=[], driver_version=None)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _gpu_details() -> dict[str, list[str | None]]:
|
|
57
|
+
"""Return each GPU's details by index, or none when the driver refuses."""
|
|
58
|
+
try:
|
|
59
|
+
rows = _query_gpus(GPU_DETAIL_FIELDS)
|
|
60
|
+
except subprocess.CalledProcessError:
|
|
61
|
+
return {}
|
|
62
|
+
return {index: [_readable_value(value) for value in rest] for index, *rest in rows}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _query_gpus(fields: tuple[str, ...]) -> list[list[str]]:
|
|
66
|
+
result = subprocess.run(
|
|
67
|
+
[
|
|
68
|
+
"nvidia-smi",
|
|
69
|
+
"--query-gpu=" + ",".join(fields),
|
|
70
|
+
"--format=csv,noheader,nounits",
|
|
71
|
+
],
|
|
72
|
+
check=True,
|
|
73
|
+
capture_output=True,
|
|
74
|
+
text=True,
|
|
75
|
+
timeout=NVIDIA_SMI_TIMEOUT_SECONDS,
|
|
76
|
+
)
|
|
77
|
+
return [
|
|
78
|
+
[value.strip() for value in row]
|
|
79
|
+
for row in csv.reader(result.stdout.splitlines())
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _parse_gpu_device(
|
|
84
|
+
index: str, name: str, memory_mib: str, details: list[str | None] | None
|
|
85
|
+
) -> RunnerGpuDevice:
|
|
86
|
+
(
|
|
87
|
+
compute_cap,
|
|
88
|
+
mig_mode,
|
|
89
|
+
ecc_mode,
|
|
90
|
+
compute_mode,
|
|
91
|
+
power_limit_w,
|
|
92
|
+
max_sm_clock_mhz,
|
|
93
|
+
pcie_generation,
|
|
94
|
+
pcie_width,
|
|
95
|
+
) = details or [None] * (len(GPU_DETAIL_FIELDS) - 1)
|
|
96
|
+
return RunnerGpuDevice(
|
|
97
|
+
index=int(index),
|
|
98
|
+
name=name,
|
|
99
|
+
memory_gb=round(int(memory_mib) / MEBIBYTES_PER_GIBIBYTE),
|
|
100
|
+
compute_cap=compute_cap,
|
|
101
|
+
is_mig_enabled=_is_enabled(mig_mode),
|
|
102
|
+
is_ecc_enabled=_is_enabled(ecc_mode),
|
|
103
|
+
compute_mode=compute_mode,
|
|
104
|
+
power_limit_w=None if power_limit_w is None else float(power_limit_w),
|
|
105
|
+
max_sm_clock_mhz=None if max_sm_clock_mhz is None else int(max_sm_clock_mhz),
|
|
106
|
+
pcie_generation=None if pcie_generation is None else int(pcie_generation),
|
|
107
|
+
pcie_width=None if pcie_width is None else int(pcie_width),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _readable_value(value: str) -> str | None:
|
|
112
|
+
if value.startswith(UNREADABLE_VALUE_PREFIX):
|
|
113
|
+
return None
|
|
114
|
+
return value
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_enabled(mode: str | None) -> bool | None:
|
|
118
|
+
if mode is None:
|
|
119
|
+
return None
|
|
120
|
+
return mode == "Enabled"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def gpu_usage() -> list[GpuUsage]:
|
|
124
|
+
"""Return the utilisation and memory use of each visible GPU.
|
|
125
|
+
|
|
126
|
+
Leave out a GPU for which `nvidia-smi` prints any of the values in
|
|
127
|
+
brackets, and return an empty list when the probe fails.
|
|
128
|
+
"""
|
|
129
|
+
argv = [
|
|
130
|
+
"nvidia-smi",
|
|
131
|
+
"--query-gpu=index,name,utilization.gpu,memory.used,memory.total",
|
|
132
|
+
"--format=csv,noheader,nounits",
|
|
133
|
+
]
|
|
134
|
+
try:
|
|
135
|
+
result = subprocess.run(
|
|
136
|
+
argv,
|
|
137
|
+
check=True,
|
|
138
|
+
capture_output=True,
|
|
139
|
+
text=True,
|
|
140
|
+
timeout=NVIDIA_SMI_TIMEOUT_SECONDS,
|
|
141
|
+
)
|
|
142
|
+
rows = [
|
|
143
|
+
[value.strip() for value in row]
|
|
144
|
+
for row in csv.reader(result.stdout.splitlines())
|
|
145
|
+
]
|
|
146
|
+
return [
|
|
147
|
+
GpuUsage(
|
|
148
|
+
index=int(index),
|
|
149
|
+
name=name,
|
|
150
|
+
utilization_percent=float(utilization),
|
|
151
|
+
memory_used_bytes=int(memory_used_mib) * BYTES_PER_MEBIBYTE,
|
|
152
|
+
memory_total_bytes=int(memory_total_mib) * BYTES_PER_MEBIBYTE,
|
|
153
|
+
)
|
|
154
|
+
for index, name, utilization, memory_used_mib, memory_total_mib in rows
|
|
155
|
+
if not any(
|
|
156
|
+
value.startswith(UNREADABLE_VALUE_PREFIX)
|
|
157
|
+
for value in (utilization, memory_used_mib, memory_total_mib)
|
|
158
|
+
)
|
|
159
|
+
]
|
|
160
|
+
except (
|
|
161
|
+
FileNotFoundError,
|
|
162
|
+
subprocess.CalledProcessError,
|
|
163
|
+
subprocess.TimeoutExpired,
|
|
164
|
+
ValueError,
|
|
165
|
+
):
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def gpu_memory_gb(devices: list[RunnerGpuDevice]) -> int:
|
|
170
|
+
"""Return the smallest visible capacity, the floor a worker can rely on."""
|
|
171
|
+
return min((device.memory_gb for device in devices), default=0)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def round_robin_gpu_device(devices: list[int], index: int) -> int | None:
|
|
175
|
+
"""The device the worker at `index` lands on, cycling through `devices`.
|
|
176
|
+
|
|
177
|
+
None where the box exposes no GPU, so a worker's launch args fall back to
|
|
178
|
+
every card rather than a device index that names none of them.
|
|
179
|
+
"""
|
|
180
|
+
if len(devices) == 0:
|
|
181
|
+
return None
|
|
182
|
+
return devices[index % len(devices)]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Beating a side for as long as the server leaves it here.
|
|
2
|
+
|
|
3
|
+
The server reclaims a side that goes quiet for the window it is swept
|
|
4
|
+
against, so a side that is serving has to say so. The beat is also the only
|
|
5
|
+
thing that says the side is gone: it is a conditional write, and one that
|
|
6
|
+
matches no row is the instruction to stop the worker. Nothing else reports that
|
|
7
|
+
a peer failed, that the run was cancelled, or that a sweep reclaimed this side,
|
|
8
|
+
and nothing else needs to. The refusal includes the run's status, and
|
|
9
|
+
`_beat_until_lost` handles a terminal status as a run that ended rather than a
|
|
10
|
+
side the sweep reclaimed.
|
|
11
|
+
|
|
12
|
+
A peer that completed is the same instruction arriving as an answer rather than
|
|
13
|
+
a refusal, because the run is over and the side is still assigned to this
|
|
14
|
+
runner. A policy container serves until it is stopped, so its own worker ending
|
|
15
|
+
is not what says the run is done, and a side that only stopped when its peer
|
|
16
|
+
failed would serve a finished run until something reclaimed it.
|
|
17
|
+
|
|
18
|
+
A side is beaten for as long as its work is driven, not from the moment the
|
|
19
|
+
policy accepts the benchmark's signature. Until then the heartbeat is sent with
|
|
20
|
+
`serving=false`: the server does not change the side, so the startup window
|
|
21
|
+
still covers loading an image, and it marks the runner seen and refuses the
|
|
22
|
+
heartbeat once the run is released. The first serving heartbeat is sent once
|
|
23
|
+
the policy has accepted the benchmark's signature, and the server moves the side
|
|
24
|
+
to `running` on that heartbeat.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import contextlib
|
|
29
|
+
from collections.abc import Coroutine
|
|
30
|
+
from typing import Any, NoReturn
|
|
31
|
+
|
|
32
|
+
from rich.markup import escape
|
|
33
|
+
|
|
34
|
+
from manifold_cli.clients.manifold import ManifoldApiClient
|
|
35
|
+
from manifold_cli.models.run import (
|
|
36
|
+
TERMINAL_STATUSES,
|
|
37
|
+
Assignment,
|
|
38
|
+
AssignmentNotAssignedToRunnerError,
|
|
39
|
+
RunSide,
|
|
40
|
+
RunStatus,
|
|
41
|
+
SideOutcome,
|
|
42
|
+
)
|
|
43
|
+
from manifold_cli.runner.plan import RunnerError
|
|
44
|
+
from manifold_cli.runner.workload import Log
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PeerCompletedError(Exception):
|
|
48
|
+
"""The run's other side completed, so this one has nothing left to serve.
|
|
49
|
+
|
|
50
|
+
Raised out of the beat rather than returned, so the work being beaten for is
|
|
51
|
+
cancelled where it stands and the worker's teardown runs, as it does when
|
|
52
|
+
the side is refused. The side that catches this reports completed: it served
|
|
53
|
+
the whole run, and the peer's outcome is the run's.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RunEndedError(Exception):
|
|
58
|
+
"""The run is terminal, so the server ended this side with the run.
|
|
59
|
+
|
|
60
|
+
Raised out of the heartbeat for the same reason as `PeerCompletedError`.
|
|
61
|
+
The side that catches this does not report an outcome, because the server
|
|
62
|
+
has already ended the side.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, status: RunStatus):
|
|
66
|
+
super().__init__(f"the run was {status.value}")
|
|
67
|
+
self.status = status
|
|
68
|
+
|
|
69
|
+
def stopped_line(self, side: RunSide) -> str:
|
|
70
|
+
"""The log line for `side` stopping, in red unless the stop is expected."""
|
|
71
|
+
line = f"The run was {self.status.value}, so the {side.value} stopped."
|
|
72
|
+
if self.status in _EXPECTED_ENDINGS:
|
|
73
|
+
return line
|
|
74
|
+
return f"[red]{line}[/red]"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_EXPECTED_ENDINGS = frozenset({RunStatus.CANCELED, RunStatus.COMPLETED})
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def while_beating[T](
|
|
81
|
+
work: Coroutine[Any, Any, T],
|
|
82
|
+
*,
|
|
83
|
+
client: ManifoldApiClient,
|
|
84
|
+
assignment: Assignment,
|
|
85
|
+
period_s: float,
|
|
86
|
+
ready: asyncio.Event,
|
|
87
|
+
log: Log,
|
|
88
|
+
side_lost_event: asyncio.Event,
|
|
89
|
+
) -> T:
|
|
90
|
+
"""Drive `work`, beating the side for as long as `work` runs.
|
|
91
|
+
|
|
92
|
+
`assignment` is the attempt returned by the dispatch. Each heartbeat
|
|
93
|
+
includes its identifiers, and errors use its side.
|
|
94
|
+
|
|
95
|
+
`ready` is set once the policy has accepted the benchmark's signature. Until
|
|
96
|
+
it is set the side is beaten with `serving=false`: the server keeps the side
|
|
97
|
+
in the startup window rather than measuring it against the heartbeat window
|
|
98
|
+
for the whole of its image pull, and refuses the next beat once the run is
|
|
99
|
+
released, rather than the first serving one. Setting `ready` ends the wait
|
|
100
|
+
between beats, so the first serving beat is sent at once.
|
|
101
|
+
|
|
102
|
+
`side_lost_event` is set once the server has reclaimed the side, and before
|
|
103
|
+
the work is cancelled.
|
|
104
|
+
|
|
105
|
+
Returns what `work` returned. Raises RunEndedError where the server refused
|
|
106
|
+
the heartbeat because the run is terminal, RunnerError where it took the side
|
|
107
|
+
back otherwise, and PeerCompletedError where it answered that the run is
|
|
108
|
+
over, having cancelled the work in every case: the worker's teardown runs on
|
|
109
|
+
that cancellation, the same way it does when its bridge dies.
|
|
110
|
+
"""
|
|
111
|
+
working = asyncio.ensure_future(work)
|
|
112
|
+
beating = asyncio.ensure_future(
|
|
113
|
+
_beat_until_lost(
|
|
114
|
+
client,
|
|
115
|
+
assignment=assignment,
|
|
116
|
+
period_s=period_s,
|
|
117
|
+
ready=ready,
|
|
118
|
+
log=log,
|
|
119
|
+
side_lost_event=side_lost_event,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
try:
|
|
123
|
+
await asyncio.wait({working, beating}, return_when=asyncio.FIRST_COMPLETED)
|
|
124
|
+
if working.done():
|
|
125
|
+
# Raises whatever the work raised, which is this side's outcome.
|
|
126
|
+
return working.result()
|
|
127
|
+
# Never returns: the beat only finishes by raising.
|
|
128
|
+
return await beating
|
|
129
|
+
finally:
|
|
130
|
+
for task in (working, beating):
|
|
131
|
+
await _cancel_and_wait(task)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def _beat_until_lost(
|
|
135
|
+
client: ManifoldApiClient,
|
|
136
|
+
*,
|
|
137
|
+
assignment: Assignment,
|
|
138
|
+
period_s: float,
|
|
139
|
+
ready: asyncio.Event,
|
|
140
|
+
log: Log,
|
|
141
|
+
side_lost_event: asyncio.Event,
|
|
142
|
+
) -> NoReturn:
|
|
143
|
+
"""Beat every `period_s` until the server refuses to take one.
|
|
144
|
+
|
|
145
|
+
Replay uploads use their own executor, and all heartbeats for a side use
|
|
146
|
+
one asynchronous client. Replay uploads therefore cannot delay a
|
|
147
|
+
heartbeat. Ending the side cancels any in-flight request and closes the
|
|
148
|
+
client before the side's event loop ends.
|
|
149
|
+
|
|
150
|
+
A beat that does not reach Manifold is logged and beaten again. The window
|
|
151
|
+
holds several beats, so one lost to a deployment that is restarting does not
|
|
152
|
+
cost the side. A revoked credential is refused the same way here, and the
|
|
153
|
+
daemon's next poll is what ends the process.
|
|
154
|
+
|
|
155
|
+
Raises RunEndedError once the server refuses a heartbeat for a terminal run,
|
|
156
|
+
RunnerError once it has reclaimed the side otherwise, and
|
|
157
|
+
PeerCompletedError once it says the peer has completed.
|
|
158
|
+
"""
|
|
159
|
+
side = assignment.side
|
|
160
|
+
async with client.assignment_heartbeats() as heartbeats:
|
|
161
|
+
while True:
|
|
162
|
+
serving = ready.is_set()
|
|
163
|
+
try:
|
|
164
|
+
beat = await heartbeats.send(assignment, serving=serving)
|
|
165
|
+
except AssignmentNotAssignedToRunnerError as exc:
|
|
166
|
+
if exc.run_status is not None and exc.run_status in TERMINAL_STATUSES:
|
|
167
|
+
raise RunEndedError(exc.run_status) from exc
|
|
168
|
+
side_lost_event.set()
|
|
169
|
+
raise RunnerError(
|
|
170
|
+
f"The server reclaimed the {side.value}: {exc}"
|
|
171
|
+
) from exc
|
|
172
|
+
except Exception as exc:
|
|
173
|
+
log(f"A heartbeat did not reach Manifold: {escape(str(exc))}")
|
|
174
|
+
else:
|
|
175
|
+
if beat.peer_outcome is SideOutcome.COMPLETED:
|
|
176
|
+
raise PeerCompletedError(f"the {_peer_of(side).value} completed")
|
|
177
|
+
if serving:
|
|
178
|
+
await asyncio.sleep(period_s)
|
|
179
|
+
else:
|
|
180
|
+
# Wait on `ready` as well as the period, so the first serving
|
|
181
|
+
# beat is sent when the worker comes up rather than at the next
|
|
182
|
+
# tick.
|
|
183
|
+
with contextlib.suppress(TimeoutError):
|
|
184
|
+
await asyncio.wait_for(ready.wait(), period_s)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def _cancel_and_wait(task: asyncio.Task[Any]) -> None:
|
|
188
|
+
"""Cancel `task` and finish its cleanup despite repeated cancellation."""
|
|
189
|
+
if not task.done():
|
|
190
|
+
task.cancel()
|
|
191
|
+
while not task.done():
|
|
192
|
+
try:
|
|
193
|
+
await asyncio.shield(task)
|
|
194
|
+
except asyncio.CancelledError:
|
|
195
|
+
continue
|
|
196
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
197
|
+
task.exception()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _peer_of(side: RunSide) -> RunSide:
|
|
201
|
+
"""The side of the pairing assigned to the other runner."""
|
|
202
|
+
return RunSide.POLICY if side is RunSide.BENCHMARK else RunSide.BENCHMARK
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Worker plans with no container, no GPU, and nothing to pull.
|
|
2
|
+
|
|
3
|
+
The loopback pair is the double for the adapter contract, and what the first
|
|
4
|
+
end-to-end run exercises: the policy worker serves the built-in echo policy on
|
|
5
|
+
the address it is given, and the benchmark worker round-trips payloads to it
|
|
6
|
+
through the bridge and writes the results rollup a benchmark image writes. Both
|
|
7
|
+
are modules of this CLI, spawned with the interpreter running the runner, so a
|
|
8
|
+
plan they produce needs no operator setup.
|
|
9
|
+
|
|
10
|
+
They ignore a version's `config`, the cache directory and the GPU arguments;
|
|
11
|
+
the plan they build is fixed. They do not ignore the shard: a double that ran
|
|
12
|
+
the whole episode set however it was partitioned would not stand in for the
|
|
13
|
+
thing being doubled.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
from uuid import UUID
|
|
20
|
+
|
|
21
|
+
from manifold_cli.runner.plan import (
|
|
22
|
+
RESULTS_ROOT,
|
|
23
|
+
ProcessOnly,
|
|
24
|
+
ShardAssignment,
|
|
25
|
+
SidePreparation,
|
|
26
|
+
TcpProbe,
|
|
27
|
+
WireBinding,
|
|
28
|
+
WorkerPlan,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# An echo policy binds its socket as soon as the interpreter is up.
|
|
32
|
+
READY_TIMEOUT_SECONDS = 5.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LoopbackAdapter:
|
|
36
|
+
"""Plans the echo policy and the ping benchmark that talk to each other."""
|
|
37
|
+
|
|
38
|
+
def plan_policy(
|
|
39
|
+
self,
|
|
40
|
+
config: dict[str, Any],
|
|
41
|
+
*,
|
|
42
|
+
run_id: UUID,
|
|
43
|
+
assignment_id: UUID,
|
|
44
|
+
wire: WireBinding,
|
|
45
|
+
cache_dir: Path | None = None,
|
|
46
|
+
gpu_device: int | None = None,
|
|
47
|
+
) -> WorkerPlan:
|
|
48
|
+
"""Plan an echo policy serving on `wire`."""
|
|
49
|
+
return WorkerPlan(
|
|
50
|
+
argv=(
|
|
51
|
+
sys.executable,
|
|
52
|
+
# Unbuffered: the runner pipes a worker's output, and a pipe
|
|
53
|
+
# makes the interpreter block-buffer, which would hold these
|
|
54
|
+
# lines back until the worker exited.
|
|
55
|
+
"-u",
|
|
56
|
+
"-m",
|
|
57
|
+
"manifold_cli.runner.loopback_policy",
|
|
58
|
+
"--listen",
|
|
59
|
+
wire.address,
|
|
60
|
+
),
|
|
61
|
+
readiness=TcpProbe(wire=wire, timeout_s=READY_TIMEOUT_SECONDS),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def plan_side_preparation(self, config: dict[str, Any]) -> SidePreparation | None:
|
|
65
|
+
"""Do not plan a preparation for the built-in workers."""
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
def plan_benchmark(
|
|
69
|
+
self,
|
|
70
|
+
config: dict[str, Any],
|
|
71
|
+
*,
|
|
72
|
+
run_id: UUID,
|
|
73
|
+
assignment_id: UUID,
|
|
74
|
+
wire: WireBinding,
|
|
75
|
+
shard: ShardAssignment,
|
|
76
|
+
live_view_wire: WireBinding | None = None,
|
|
77
|
+
results_root: Path = RESULTS_ROOT,
|
|
78
|
+
cache_dir: Path | None = None,
|
|
79
|
+
gpu_device: int | None = None,
|
|
80
|
+
) -> WorkerPlan:
|
|
81
|
+
"""Plan a ping benchmark driving `shard`, writing under `results_root`."""
|
|
82
|
+
output_dir = results_root / run_id.hex / shard.label
|
|
83
|
+
return WorkerPlan(
|
|
84
|
+
argv=(
|
|
85
|
+
sys.executable,
|
|
86
|
+
"-u",
|
|
87
|
+
"-m",
|
|
88
|
+
"manifold_cli.runner.loopback_bench",
|
|
89
|
+
"--server",
|
|
90
|
+
wire.address,
|
|
91
|
+
"--output-dir",
|
|
92
|
+
str(output_dir),
|
|
93
|
+
"--shard-index",
|
|
94
|
+
str(shard.index),
|
|
95
|
+
"--num-shards",
|
|
96
|
+
str(shard.count),
|
|
97
|
+
"--total-episodes",
|
|
98
|
+
str(shard.total_episodes),
|
|
99
|
+
),
|
|
100
|
+
readiness=ProcessOnly(),
|
|
101
|
+
results_dir=output_dir / "results",
|
|
102
|
+
)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""The stand-in benchmark worker: round-trips payloads through the bridge.
|
|
2
|
+
|
|
3
|
+
`LoopbackAdapter` spawns this module. It stands in for a benchmark container: it
|
|
4
|
+
dials the address the benchmark bridge accepts on, drives its shard of the run's
|
|
5
|
+
episodes as echo round-trips against the policy on the far side, and writes a
|
|
6
|
+
results rollup under its output directory, where a benchmark image writes its
|
|
7
|
+
results.
|
|
8
|
+
|
|
9
|
+
The episode count is the run's, taken from `--total-episodes`, as a real image
|
|
10
|
+
takes it from the run rather than deciding it. This module only says how a
|
|
11
|
+
single episode is driven.
|
|
12
|
+
|
|
13
|
+
The rollup is what the runner reads back from a benchmark image, because the
|
|
14
|
+
bridge relays the wire verbatim and an image's own protocol never reaches the
|
|
15
|
+
runner. So this one carries the layout and the shape a real image writes:
|
|
16
|
+
`<output-dir>/results/<benchmark>.json`, a flat document keyed `records` with one
|
|
17
|
+
entry per episode -- what manifold-sdk's `write_rollup` leaves behind and
|
|
18
|
+
`parse_episode_rollup` reads back. The runner scans the results directory in a
|
|
19
|
+
later slice.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import asyncio
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
from collections.abc import Sequence
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from datetime import UTC, datetime
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from manifold_cli.transport.diagnostics import ping
|
|
32
|
+
from manifold_cli.transport.interfaces import Connector
|
|
33
|
+
from manifold_cli.transport.tcp import TcpConnector, split_host_port
|
|
34
|
+
|
|
35
|
+
BENCHMARK = "loopback"
|
|
36
|
+
|
|
37
|
+
# One round-trip stands in for one step: the echo policy answers every payload,
|
|
38
|
+
# as a policy answers an observation with an action.
|
|
39
|
+
STEPS_PER_EPISODE = 4
|
|
40
|
+
|
|
41
|
+
# One entry per task, with the payload every round-trip of it carries. The
|
|
42
|
+
# global episode set cycles them, so how many episodes there are is the run's
|
|
43
|
+
# to state and not this module's. Two of them, because a record's episode index
|
|
44
|
+
# is global across tasks and a rollup of a single task would not show it.
|
|
45
|
+
TASKS: tuple[tuple[str, int], ...] = (
|
|
46
|
+
("echo-4k", 4 * 1024),
|
|
47
|
+
("echo-64k", 64 * 1024),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class EpisodeRecord:
|
|
53
|
+
"""One episode as the rollup reports it."""
|
|
54
|
+
|
|
55
|
+
episode_idx: int
|
|
56
|
+
task_name: str
|
|
57
|
+
success: bool
|
|
58
|
+
steps: int
|
|
59
|
+
initialization_sec: float
|
|
60
|
+
started_at: datetime
|
|
61
|
+
ended_at: datetime
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main() -> None:
|
|
65
|
+
parser = argparse.ArgumentParser(description="Ping a policy through the bridge.")
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--server", required=True, help="Address to dial, as HOST:PORT."
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--output-dir", required=True, help="Directory to write the rollup under."
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--total-episodes",
|
|
74
|
+
required=True,
|
|
75
|
+
type=int,
|
|
76
|
+
help="Episodes the whole run drives, which the shards divide.",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--num-shards", required=True, type=int, help="Shards the run is split into."
|
|
80
|
+
)
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--shard-index", required=True, type=int, help="Which of them this one is."
|
|
83
|
+
)
|
|
84
|
+
args = parser.parse_args()
|
|
85
|
+
raise SystemExit(
|
|
86
|
+
asyncio.run(
|
|
87
|
+
_run(
|
|
88
|
+
args.server,
|
|
89
|
+
Path(args.output_dir),
|
|
90
|
+
total_episodes=args.total_episodes,
|
|
91
|
+
num_shards=args.num_shards,
|
|
92
|
+
shard_index=args.shard_index,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _episode_ids(total_episodes: int, num_shards: int, shard_index: int) -> list[int]:
|
|
99
|
+
"""The global episode ids this shard owns, as manifold-sdk partitions them.
|
|
100
|
+
|
|
101
|
+
Shard `i` owns every index where `idx % num_shards == i`, which covers the
|
|
102
|
+
set exactly once. The ids stay global, so a record names the episode the run
|
|
103
|
+
planned rather than the position it happened to hold in one shard.
|
|
104
|
+
"""
|
|
105
|
+
return [idx for idx in range(total_episodes) if idx % num_shards == shard_index]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def _run(
|
|
109
|
+
server: str,
|
|
110
|
+
output_dir: Path,
|
|
111
|
+
*,
|
|
112
|
+
total_episodes: int,
|
|
113
|
+
num_shards: int,
|
|
114
|
+
shard_index: int,
|
|
115
|
+
) -> int:
|
|
116
|
+
host, port = split_host_port(server)
|
|
117
|
+
connector = TcpConnector(host, port)
|
|
118
|
+
records: list[EpisodeRecord] = []
|
|
119
|
+
for episode_idx in _episode_ids(total_episodes, num_shards, shard_index):
|
|
120
|
+
task, payload_bytes = TASKS[episode_idx % len(TASKS)]
|
|
121
|
+
record = await _run_episode(
|
|
122
|
+
connector, task, payload_bytes, episode_idx=episode_idx
|
|
123
|
+
)
|
|
124
|
+
elapsed_ms = (record.ended_at - record.started_at).total_seconds() * 1000
|
|
125
|
+
_say(
|
|
126
|
+
f"{task} episode {record.episode_idx}: {record.steps} round-trips "
|
|
127
|
+
f"of {payload_bytes} bytes in {elapsed_ms:.2f} ms"
|
|
128
|
+
)
|
|
129
|
+
records.append(record)
|
|
130
|
+
path = _write_rollup(output_dir, records)
|
|
131
|
+
_say(f"wrote the rollup to {path}")
|
|
132
|
+
return 0 if all(record.success for record in records) else 1
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def _run_episode(
|
|
136
|
+
connector: Connector, task: str, payload_bytes: int, *, episode_idx: int
|
|
137
|
+
) -> EpisodeRecord:
|
|
138
|
+
"""Round-trip one payload per step, and record what the steps came to."""
|
|
139
|
+
started_at = datetime.now(UTC)
|
|
140
|
+
matched = 0
|
|
141
|
+
for _ in range(STEPS_PER_EPISODE):
|
|
142
|
+
result = await ping(connector, payload=os.urandom(payload_bytes))
|
|
143
|
+
matched += 1 if result.matched else 0
|
|
144
|
+
return EpisodeRecord(
|
|
145
|
+
episode_idx=episode_idx,
|
|
146
|
+
task_name=task,
|
|
147
|
+
success=matched == STEPS_PER_EPISODE,
|
|
148
|
+
steps=STEPS_PER_EPISODE,
|
|
149
|
+
initialization_sec=0.0,
|
|
150
|
+
started_at=started_at,
|
|
151
|
+
ended_at=datetime.now(UTC),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _write_rollup(output_dir: Path, records: Sequence[EpisodeRecord]) -> Path:
|
|
156
|
+
"""Write the rollup where a supervisor scans for one."""
|
|
157
|
+
rollup = {
|
|
158
|
+
"records": [
|
|
159
|
+
{
|
|
160
|
+
"episode_idx": record.episode_idx,
|
|
161
|
+
"task_name": record.task_name,
|
|
162
|
+
"success": record.success,
|
|
163
|
+
"steps": record.steps,
|
|
164
|
+
"initialization_sec": record.initialization_sec,
|
|
165
|
+
"started_at": record.started_at.isoformat(),
|
|
166
|
+
"ended_at": record.ended_at.isoformat(),
|
|
167
|
+
}
|
|
168
|
+
for record in records
|
|
169
|
+
]
|
|
170
|
+
}
|
|
171
|
+
results_dir = output_dir / "results"
|
|
172
|
+
results_dir.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
path = results_dir / f"{BENCHMARK}.json"
|
|
174
|
+
path.write_text(json.dumps(rollup))
|
|
175
|
+
return path
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _say(message: str) -> None:
|
|
179
|
+
print(message, flush=True)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
main()
|