Skip to content

Commit b7ea456

Browse files
committed
Add SSH polling to mesh GPU snapshots
1 parent 73866b7 commit b7ea456

3 files changed

Lines changed: 95 additions & 22 deletions

File tree

docs/ci_and_scripts.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1089,7 +1089,8 @@ python3 scripts/mesh_worker_gpu_snapshot_selftest.py
10891089

10901090
### `mesh_worker_gpu_snapshot.py` and `mesh_worker_gpu_reconcile.py`
10911091

1092-
`mesh_worker_gpu_snapshot.py` emits host-local `nvidia-smi` GPU/process state.
1092+
`mesh_worker_gpu_snapshot.py` emits host-local `nvidia-smi` GPU/process state,
1093+
or SSH-polled state with `--ssh-host` for the scheduler-VM v0 deployment model.
10931094
`mesh_worker_gpu_reconcile.py` compares that worker snapshot with central
10941095
arbiter lease state and reports a failed reconciliation when CUDA processes are
10951096
running without an active lease for the resource. Feed reconciliation JSON into
@@ -1098,6 +1099,10 @@ GPU activity fail the control-plane audit.
10981099

10991100
```sh
11001101
python3 scripts/mesh_worker_gpu_snapshot.py --resource cosbox:cuda3090 --json
1102+
python3 scripts/mesh_worker_gpu_snapshot.py \
1103+
--resource cosbox:cuda3090 \
1104+
--ssh-host cosbox \
1105+
--json
11011106
python3 scripts/mesh_worker_gpu_reconcile.py \
11021107
--snapshot-json worker_gpu_snapshot.json \
11031108
--arbiter-status-json arbiter_status.json \

scripts/mesh_worker_gpu_snapshot.py

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!/usr/bin/env python3
2-
"""Emit host-local GPU process state for scheduler reconciliation."""
2+
"""Emit local or SSH-polled GPU process state for scheduler reconciliation."""
33

44
from __future__ import annotations
55

@@ -30,6 +30,12 @@ def run_capture(argv: list[str], *, timeout: float) -> subprocess.CompletedProce
3030
)
3131

3232

33+
def nvidia_smi_command(args: argparse.Namespace, nvidia_smi_args: list[str]) -> list[str]:
34+
if args.ssh_host:
35+
return ["ssh", args.ssh_host, args.nvidia_smi, *nvidia_smi_args]
36+
return [args.nvidia_smi, *nvidia_smi_args]
37+
38+
3339
def parse_compute_apps(stdout: str) -> list[dict[str, Any]]:
3440
apps: list[dict[str, Any]] = []
3541
for row in csv.reader(io.StringIO(stdout)):
@@ -92,15 +98,17 @@ def as_int(value: str) -> int | None:
9298
return rows
9399

94100

95-
def query_compute_apps(nvidia_smi: str, timeout: float) -> dict[str, Any]:
101+
def query_compute_apps(args: argparse.Namespace) -> dict[str, Any]:
96102
try:
97103
proc = run_capture(
98-
[
99-
nvidia_smi,
100-
"--query-compute-apps=pid,process_name,used_gpu_memory",
101-
"--format=csv,noheader,nounits",
102-
],
103-
timeout=timeout,
104+
nvidia_smi_command(
105+
args,
106+
[
107+
"--query-compute-apps=pid,process_name,used_gpu_memory",
108+
"--format=csv,noheader,nounits",
109+
],
110+
),
111+
timeout=args.timeout_sec,
104112
)
105113
except FileNotFoundError as exc:
106114
return {"ok": False, "reason": "nvidia_smi_not_found", "error": str(exc), "apps": []}
@@ -115,15 +123,17 @@ def query_compute_apps(nvidia_smi: str, timeout: float) -> dict[str, Any]:
115123
}
116124

117125

118-
def query_gpus(nvidia_smi: str, timeout: float) -> dict[str, Any]:
126+
def query_gpus(args: argparse.Namespace) -> dict[str, Any]:
119127
try:
120128
proc = run_capture(
121-
[
122-
nvidia_smi,
123-
"--query-gpu=index,uuid,pci.bus_id,name,memory.total,memory.used,memory.free,utilization.gpu",
124-
"--format=csv,noheader,nounits",
125-
],
126-
timeout=timeout,
129+
nvidia_smi_command(
130+
args,
131+
[
132+
"--query-gpu=index,uuid,pci.bus_id,name,memory.total,memory.used,memory.free,utilization.gpu",
133+
"--format=csv,noheader,nounits",
134+
],
135+
),
136+
timeout=args.timeout_sec,
127137
)
128138
except FileNotFoundError as exc:
129139
return {"ok": False, "reason": "nvidia_smi_not_found", "error": str(exc), "gpus": []}
@@ -146,22 +156,24 @@ def proc_cmdline(pid: int) -> str:
146156
return ""
147157

148158

149-
def enrich_apps(apps: list[dict[str, Any]]) -> list[dict[str, Any]]:
159+
def enrich_apps(apps: list[dict[str, Any]], *, include_cmdline: bool) -> list[dict[str, Any]]:
150160
enriched = []
151161
for app in apps:
152162
row = dict(app)
153163
pid = row.get("pid")
154-
if isinstance(pid, int):
164+
if include_cmdline and isinstance(pid, int):
155165
row["cmdline"] = proc_cmdline(pid)
156166
enriched.append(row)
157167
return enriched
158168

159169

160170
def build_payload(args: argparse.Namespace) -> dict[str, Any]:
161-
gpu_query = query_gpus(args.nvidia_smi, args.timeout_sec)
162-
compute_query = query_compute_apps(args.nvidia_smi, args.timeout_sec)
163-
apps = enrich_apps(compute_query.get("apps", []))
171+
gpu_query = query_gpus(args)
172+
compute_query = query_compute_apps(args)
173+
apps = enrich_apps(compute_query.get("apps", []), include_cmdline=not args.ssh_host)
164174
ok = bool(gpu_query.get("ok")) and bool(compute_query.get("ok"))
175+
poller_host = socket.gethostname()
176+
worker_host = args.ssh_host or poller_host
165177
reasons = []
166178
if not gpu_query.get("ok"):
167179
reasons.append(str(gpu_query.get("reason") or "gpu_query_failed"))
@@ -172,7 +184,9 @@ def build_payload(args: argparse.Namespace) -> dict[str, Any]:
172184
"ok": ok,
173185
"reason": "ok" if ok else ",".join(reasons),
174186
"checked_at_unix": time.time(),
175-
"worker_host": socket.gethostname(),
187+
"worker_host": worker_host,
188+
"poller_host": poller_host,
189+
"ssh_host": args.ssh_host,
176190
"resource": args.resource,
177191
"nvidia_smi": args.nvidia_smi,
178192
"gpus": gpu_query.get("gpus", []),
@@ -188,6 +202,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
188202
parser = argparse.ArgumentParser(description=__doc__)
189203
parser.add_argument("--resource", default="cuda")
190204
parser.add_argument("--nvidia-smi", default="nvidia-smi")
205+
parser.add_argument("--ssh-host", default="")
191206
parser.add_argument("--timeout-sec", type=float, default=10.0)
192207
parser.add_argument("--json", action="store_true")
193208
args = parser.parse_args(argv)

scripts/mesh_worker_gpu_snapshot_selftest.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55

66
import importlib.machinery
77
import importlib.util
8+
import argparse
89
import pathlib
10+
import subprocess
911
from types import ModuleType
1012

1113

@@ -32,6 +34,55 @@ def test_snapshot_parses_nvidia_smi_rows() -> None:
3234
assert gpus[0]["memory_total_mib"] == 24576
3335

3436

37+
def test_snapshot_supports_ssh_polling_command_prefix() -> None:
38+
mod = load_script("mesh_worker_gpu_snapshot_under_test", ROOT / "scripts" / "mesh_worker_gpu_snapshot.py")
39+
args = argparse.Namespace(ssh_host="cosbox", nvidia_smi="nvidia-smi")
40+
argv = mod.nvidia_smi_command(args, ["--query-gpu=index"])
41+
assert argv == ["ssh", "cosbox", "nvidia-smi", "--query-gpu=index"]
42+
args.ssh_host = ""
43+
assert mod.nvidia_smi_command(args, ["--query-gpu=index"]) == [
44+
"nvidia-smi",
45+
"--query-gpu=index",
46+
]
47+
48+
49+
def test_snapshot_records_worker_and_poller_hosts_for_ssh() -> None:
50+
mod = load_script("mesh_worker_gpu_snapshot_under_test", ROOT / "scripts" / "mesh_worker_gpu_snapshot.py")
51+
52+
def fake_run_capture(argv: list[str], *, timeout: float) -> subprocess.CompletedProcess[str]:
53+
del timeout
54+
if "--query-gpu=index,uuid,pci.bus_id,name,memory.total,memory.used,memory.free,utilization.gpu" in argv:
55+
return subprocess.CompletedProcess(
56+
argv,
57+
0,
58+
stdout="0, GPU-test, 00000000:01:00.0, RTX 3090, 24576, 1024, 23552, 7\n",
59+
stderr="",
60+
)
61+
return subprocess.CompletedProcess(argv, 0, stdout="1234, python, 8192\n", stderr="")
62+
63+
original_run_capture = mod.run_capture
64+
original_hostname = mod.socket.gethostname
65+
mod.run_capture = fake_run_capture
66+
mod.socket.gethostname = lambda: "poller"
67+
try:
68+
payload = mod.build_payload(
69+
argparse.Namespace(
70+
resource="cosbox:cuda3090",
71+
nvidia_smi="nvidia-smi",
72+
ssh_host="cosbox",
73+
timeout_sec=1.0,
74+
)
75+
)
76+
finally:
77+
mod.run_capture = original_run_capture
78+
mod.socket.gethostname = original_hostname
79+
80+
assert payload["worker_host"] == "cosbox"
81+
assert payload["poller_host"] == "poller"
82+
assert payload["cuda_pids"] == [1234]
83+
assert "cmdline" not in payload["cuda_apps"][0]
84+
85+
3586
def test_reconcile_drains_unleased_cuda() -> None:
3687
mod = load_script("mesh_worker_gpu_reconcile_under_test", ROOT / "scripts" / "mesh_worker_gpu_reconcile.py")
3788
snapshot = {
@@ -65,6 +116,8 @@ def test_reconcile_allows_leased_cuda() -> None:
65116

66117
def main() -> int:
67118
test_snapshot_parses_nvidia_smi_rows()
119+
test_snapshot_supports_ssh_polling_command_prefix()
120+
test_snapshot_records_worker_and_poller_hosts_for_ssh()
68121
test_reconcile_drains_unleased_cuda()
69122
test_reconcile_allows_leased_cuda()
70123
print("mesh worker GPU snapshot selftest OK")

0 commit comments

Comments
 (0)