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.
Files changed (102) hide show
  1. manifold_cli/__init__.py +3 -0
  2. manifold_cli/__main__.py +4 -0
  3. manifold_cli/bridge.py +70 -0
  4. manifold_cli/cli.py +54 -0
  5. manifold_cli/clients/__init__.py +0 -0
  6. manifold_cli/clients/auth_flow.py +169 -0
  7. manifold_cli/clients/credentials.py +53 -0
  8. manifold_cli/clients/docker_engine.py +43 -0
  9. manifold_cli/clients/image.py +130 -0
  10. manifold_cli/clients/manifold.py +1288 -0
  11. manifold_cli/clients/runner_home.py +165 -0
  12. manifold_cli/clients/workos.py +99 -0
  13. manifold_cli/commands/__init__.py +0 -0
  14. manifold_cli/commands/auth.py +101 -0
  15. manifold_cli/commands/benchmark.py +539 -0
  16. manifold_cli/commands/organization.py +154 -0
  17. manifold_cli/commands/policy.py +749 -0
  18. manifold_cli/commands/run.py +337 -0
  19. manifold_cli/commands/run_log.py +291 -0
  20. manifold_cli/commands/runner.py +852 -0
  21. manifold_cli/commands/table.py +101 -0
  22. manifold_cli/commands/token.py +144 -0
  23. manifold_cli/dependencies.py +116 -0
  24. manifold_cli/models/__init__.py +0 -0
  25. manifold_cli/models/auth.py +49 -0
  26. manifold_cli/models/benchmark.py +87 -0
  27. manifold_cli/models/catalog.py +189 -0
  28. manifold_cli/models/credentials.py +19 -0
  29. manifold_cli/models/errors.py +121 -0
  30. manifold_cli/models/log.py +114 -0
  31. manifold_cli/models/observability.py +121 -0
  32. manifold_cli/models/organization.py +19 -0
  33. manifold_cli/models/pagination.py +59 -0
  34. manifold_cli/models/policy.py +49 -0
  35. manifold_cli/models/run.py +381 -0
  36. manifold_cli/models/runner.py +186 -0
  37. manifold_cli/models/runner_home.py +69 -0
  38. manifold_cli/models/task_queue.py +40 -0
  39. manifold_cli/models/token.py +39 -0
  40. manifold_cli/replay/__init__.py +68 -0
  41. manifold_cli/replay/log.py +814 -0
  42. manifold_cli/runner/__init__.py +72 -0
  43. manifold_cli/runner/api_retry.py +36 -0
  44. manifold_cli/runner/benchmark/__init__.py +800 -0
  45. manifold_cli/runner/benchmark/live_view.py +248 -0
  46. manifold_cli/runner/benchmark/preassigned_scheduling.py +325 -0
  47. manifold_cli/runner/benchmark/recording.py +1208 -0
  48. manifold_cli/runner/benchmark/scheduling.py +256 -0
  49. manifold_cli/runner/benchmark/task_queue_scheduling.py +717 -0
  50. manifold_cli/runner/benchmark/task_server.py +123 -0
  51. manifold_cli/runner/benchmark/video.py +124 -0
  52. manifold_cli/runner/benchmark_steps.py +178 -0
  53. manifold_cli/runner/cancellation.py +22 -0
  54. manifold_cli/runner/capture.py +282 -0
  55. manifold_cli/runner/container.py +370 -0
  56. manifold_cli/runner/current_dispatch.py +25 -0
  57. manifold_cli/runner/daemon.py +520 -0
  58. manifold_cli/runner/dispatch.py +38 -0
  59. manifold_cli/runner/gpu.py +182 -0
  60. manifold_cli/runner/heartbeat.py +202 -0
  61. manifold_cli/runner/loopback.py +102 -0
  62. manifold_cli/runner/loopback_bench.py +183 -0
  63. manifold_cli/runner/loopback_policy.py +39 -0
  64. manifold_cli/runner/output.py +218 -0
  65. manifold_cli/runner/pairing_acceptance.py +57 -0
  66. manifold_cli/runner/plan.py +369 -0
  67. manifold_cli/runner/policy.py +350 -0
  68. manifold_cli/runner/policy_steps.py +164 -0
  69. manifold_cli/runner/preparation.py +47 -0
  70. manifold_cli/runner/process.py +219 -0
  71. manifold_cli/runner/ram.py +17 -0
  72. manifold_cli/runner/resource_usage.py +265 -0
  73. manifold_cli/runner/served.py +218 -0
  74. manifold_cli/runner/service.py +120 -0
  75. manifold_cli/runner/side_failure.py +68 -0
  76. manifold_cli/runner/signature.py +98 -0
  77. manifold_cli/runner/source.py +66 -0
  78. manifold_cli/runner/stages.py +241 -0
  79. manifold_cli/runner/step_timing.py +229 -0
  80. manifold_cli/runner/worker.py +534 -0
  81. manifold_cli/runner/workload.py +401 -0
  82. manifold_cli/services/__init__.py +0 -0
  83. manifold_cli/services/auth.py +89 -0
  84. manifold_cli/services/benchmark.py +357 -0
  85. manifold_cli/services/organization.py +22 -0
  86. manifold_cli/services/policy.py +234 -0
  87. manifold_cli/services/run.py +304 -0
  88. manifold_cli/services/runner.py +207 -0
  89. manifold_cli/services/runner_daemon.py +146 -0
  90. manifold_cli/services/token.py +52 -0
  91. manifold_cli/settings.py +30 -0
  92. manifold_cli/transport/__init__.py +31 -0
  93. manifold_cli/transport/diagnostics.py +72 -0
  94. manifold_cli/transport/interfaces.py +69 -0
  95. manifold_cli/transport/iroh.py +317 -0
  96. manifold_cli/transport/registry.py +34 -0
  97. manifold_cli/transport/tcp.py +146 -0
  98. manifold_cli-0.0.39.dist-info/METADATA +406 -0
  99. manifold_cli-0.0.39.dist-info/RECORD +102 -0
  100. manifold_cli-0.0.39.dist-info/WHEEL +4 -0
  101. manifold_cli-0.0.39.dist-info/entry_points.txt +2 -0
  102. 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()