hyperstudy-perception 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.
- hyperstudy_perception-0.1.0.dist-info/METADATA +53 -0
- hyperstudy_perception-0.1.0.dist-info/RECORD +82 -0
- hyperstudy_perception-0.1.0.dist-info/WHEEL +4 -0
- hyperstudy_perception-0.1.0.dist-info/entry_points.txt +2 -0
- hyperstudy_perception-0.1.0.dist-info/licenses/LICENSE +21 -0
- perception/__init__.py +0 -0
- perception/_pipeline/RELEASE.json +59 -0
- perception/_pipeline/cloud/runpod/guard.sh +37 -0
- perception/_pipeline/cloud/runpod/image/Dockerfile +39 -0
- perception/_pipeline/cloud/runpod/image/start.sh +11 -0
- perception/_pipeline/cloud/runpod/image/tag.py +40 -0
- perception/_pipeline/cloud/skypilot/gpu-faces.yaml +39 -0
- perception/_pipeline/cloud/skypilot/gpu-tune.yaml +55 -0
- perception/_pipeline/cloud/skypilot/guard.sh +20 -0
- perception/_pipeline/cloud/skypilot/iam-perception-node.json +18 -0
- perception/_pipeline/cloud/skypilot/launch.sh +63 -0
- perception/_pipeline/cloud/skypilot/node-gpu.yaml +25 -0
- perception/_pipeline/cloud/skypilot/node-setup.sh +22 -0
- perception/_pipeline/cloud/skypilot/sky.yaml +7 -0
- perception/_pipeline/cloud/skypilot/smoke.yaml +28 -0
- perception/_pipeline/envs/api/pyproject.toml +12 -0
- perception/_pipeline/envs/api/uv.lock +276 -0
- perception/_pipeline/envs/audio/pyproject.toml +12 -0
- perception/_pipeline/envs/audio/uv.lock +575 -0
- perception/_pipeline/envs/core/pyproject.toml +12 -0
- perception/_pipeline/envs/core/uv.lock +157 -0
- perception/_pipeline/envs/faces/pyproject.toml +12 -0
- perception/_pipeline/envs/faces/uv.lock +1180 -0
- perception/_pipeline/graphs/decode.yaml +11 -0
- perception/_pipeline/graphs/faces.yaml +14 -0
- perception/_pipeline/graphs/full.yaml +31 -0
- perception/_pipeline/graphs/mvp-captions.yaml +27 -0
- perception/_pipeline/graphs/mvp.yaml +17 -0
- perception/_pipeline/nodes/audio/asr/manifest.yaml +20 -0
- perception/_pipeline/nodes/audio/asr/node.py +74 -0
- perception/_pipeline/nodes/audio/asr/prepare.py +12 -0
- perception/_pipeline/nodes/audio/asr/test_wav.py +38 -0
- perception/_pipeline/nodes/audio/asr/wav.py +19 -0
- perception/_pipeline/nodes/conftest.py +64 -0
- perception/_pipeline/nodes/core/decode/manifest.yaml +19 -0
- perception/_pipeline/nodes/core/decode/media.py +104 -0
- perception/_pipeline/nodes/core/decode/node.py +56 -0
- perception/_pipeline/nodes/core/decode/test_media.py +65 -0
- perception/_pipeline/nodes/testdata/README.md +10 -0
- perception/_pipeline/nodes/testdata/rotated-90.mp4 +0 -0
- perception/_pipeline/nodes/vision/face-track/frames.py +88 -0
- perception/_pipeline/nodes/vision/face-track/loader.py +18 -0
- perception/_pipeline/nodes/vision/face-track/manifest.yaml +30 -0
- perception/_pipeline/nodes/vision/face-track/node.py +117 -0
- perception/_pipeline/nodes/vision/face-track/prepare.py +8 -0
- perception/_pipeline/nodes/vision/face-track/test_frames.py +46 -0
- perception/_pipeline/nodes/vision/face-track/test_loader.py +16 -0
- perception/_pipeline/nodes/vision/face-track/test_thumbnails.py +66 -0
- perception/_pipeline/nodes/vision/face-track/thumbnails.py +68 -0
- perception/_pipeline/nodes/vision/face-track/tracking.py +150 -0
- perception/_pipeline/nodes/vlm/shot-captions/manifest.yaml +29 -0
- perception/_pipeline/nodes/vlm/shot-captions/node.py +185 -0
- perception/_pipeline/schema/README.md +54 -0
- perception/_pipeline/schema/bundle-0.1.schema.json +371 -0
- perception/_pipeline/sdk/pyproject.toml +13 -0
- perception/_pipeline/sdk/src/perception_node/__init__.py +157 -0
- perception/artifacts.py +180 -0
- perception/backends/__init__.py +0 -0
- perception/backends/base.py +179 -0
- perception/backends/cloud.py +214 -0
- perception/backends/native.py +140 -0
- perception/backends/probe.py +44 -0
- perception/backends/remote.py +140 -0
- perception/cli.py +780 -0
- perception/estimates.py +68 -0
- perception/export.py +100 -0
- perception/hosts.py +201 -0
- perception/ir.py +245 -0
- perception/keys.py +259 -0
- perception/manifests.py +169 -0
- perception/plan.py +170 -0
- perception/release.py +61 -0
- perception/remote_driver.py +990 -0
- perception/runpod.py +100 -0
- perception/scheduler.py +552 -0
- perception/setup.py +72 -0
- perception/store.py +219 -0
|
@@ -0,0 +1,990 @@
|
|
|
1
|
+
"""Runs one attempt of a node on another machine, for the coordinator (backends/remote.py starts it):
|
|
2
|
+
|
|
3
|
+
python -m perception.remote_driver --request ATTEMPT/request.json (with ATTEMPT/remote.json beside it)
|
|
4
|
+
|
|
5
|
+
1. Copies the node's code (by content: once per version) and its inputs (by sha256: once per host) to the host.
|
|
6
|
+
2. Writes the request there, with the host's paths, and starts the node in its own session (setsid), detached,
|
|
7
|
+
so a dropped connection does not end it, with a watchdog beside it (WATCHDOG) that stops it and removes its folder
|
|
8
|
+
if no driver asks about it for a while. A previous attempt of the same node still running there is stopped first.
|
|
9
|
+
3. Watches it every few seconds, appending its log to this attempt's log.txt. A host that cannot be reached is waited
|
|
10
|
+
for (a laptop changes networks) for up to lost_after_s; after that the attempt ends without a response, which the
|
|
11
|
+
coordinator treats as a crash (retried within the run's policy), and the host's watchdog ends the node.
|
|
12
|
+
4. Brings out/ and response.json back into this attempt's folder, then removes the attempt's folder on the host.
|
|
13
|
+
|
|
14
|
+
SIGTERM (the coordinator's cancel) stops the node on the host before the driver exits. Its output (stdout) is the
|
|
15
|
+
attempt's log.txt.
|
|
16
|
+
|
|
17
|
+
On a cloud machine (spec "cloud", backends/cloud.py) the driver first starts the machine (sky launch, with the
|
|
18
|
+
repository's project config), makes an OS shutdown terminate it, probes it like a host, and runs guard.sh there (the
|
|
19
|
+
perception-node role, no AWS credentials, a shutdown at the deadline) before anything is sent. The node is stopped
|
|
20
|
+
when the deadline nears. Whatever happens, the machine is deleted (sky down) before the driver exits, and the time it
|
|
21
|
+
existed is added to the response's usage.usd (all of the deadline if it could not be deleted).
|
|
22
|
+
|
|
23
|
+
On a RunPod pod (cloud "provider": "runpod") the same steps go through RunPod's API instead: create the pod (the account
|
|
24
|
+
key in this process's environment only), wait for its SSH port, reach it with a key made for the attempt, run
|
|
25
|
+
cloud/runpod/guard.sh there (no key to the account on it, its own removal at the deadline), and delete it at the end.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import contextlib
|
|
32
|
+
import json
|
|
33
|
+
import math
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import shlex
|
|
37
|
+
import signal
|
|
38
|
+
import subprocess
|
|
39
|
+
import sys
|
|
40
|
+
import threading
|
|
41
|
+
import time
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
POLL_S = 3.0
|
|
45
|
+
# exit codes the scripts here use for a refusal that would happen again: the node's error code for each
|
|
46
|
+
REFUSALS = {3: "input", 5: "input"}
|
|
47
|
+
# how long past its deadline a perception pod is left before a RunPod launch deletes it: its own driver deletes it before
|
|
48
|
+
# its deadline, and it removes itself at it (guard.sh)
|
|
49
|
+
SWEEP_GRACE_S = 10 * 60
|
|
50
|
+
KEEP_S = 24 * 3600 # how long a finished node's results wait on the host for a driver that lost touch (WATCHDOG)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Canceled(Exception):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Unreachable(Exception):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PrepareFailed(Exception):
|
|
62
|
+
"""The node's environment could not be synced, or its prepare script failed. Not retried: these fail the same way
|
|
63
|
+
again (no wheel for the machine, an import error, a model setting that is wrong), and on a cloud place each attempt
|
|
64
|
+
pays for a machine. The log says why; a run started again tries once more."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Refused(Exception):
|
|
68
|
+
"""The host answered, and the attempt cannot go on: the same would happen again (an input that changed since the run
|
|
69
|
+
read it, a root that is not the user's). It fails as the node's own error, not a crash to retry."""
|
|
70
|
+
def __init__(self, code: str, message: str):
|
|
71
|
+
super().__init__(message)
|
|
72
|
+
self.code = code
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Deadline(Exception):
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class GuardRefused(Exception):
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class LaunchFailed(Exception):
|
|
84
|
+
"""The cloud machine did not start (no capacity, a setup that failed or hung): worth another attempt."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Publish a folder in one step: rename $1 to $2 unless $2 exists, in which case $1 is dropped (the same content, by its
|
|
88
|
+
# hash, arrived first). rename(2) onto a folder that is not empty fails, so a published folder is never replaced or
|
|
89
|
+
# nested into: GNU mv -T on Linux, perl's rename on a Mac (BSD mv has no -T and would move $1 into $2).
|
|
90
|
+
PUBLISH = r"""publish() {
|
|
91
|
+
mv -T "$1" "$2" 2>/dev/null || perl -e 'rename($ARGV[0], $ARGV[1]) or exit 1' "$1" "$2" 2>/dev/null || { chmod -R u+w "$1"; rm -rf "$1"; [ -e "$2/.complete" ]; }
|
|
92
|
+
}"""
|
|
93
|
+
|
|
94
|
+
# The host's backstop for a node its driver lost (the laptop gone, a cancel that could not reach the host): every
|
|
95
|
+
# `every` seconds, while the attempt's folder exists, if no driver has written the heartbeat (alive) for `lost` seconds
|
|
96
|
+
# and the node is still running, stop its process group (only if that pid is still this attempt's node) and remove the
|
|
97
|
+
# folder: its outputs are partial. A node that finished keeps its folder for `keep` seconds, for a driver that comes back
|
|
98
|
+
# (a laptop that slept) to fetch: its results are whole, and running it again would cost the GPU time again.
|
|
99
|
+
WATCHDOG = r"""w=$1 pid=$2 lost=$3 every=$4 keep=$5
|
|
100
|
+
while [ -d "$w" ]; do
|
|
101
|
+
sleep "$every"
|
|
102
|
+
[ -d "$w" ] || exit 0
|
|
103
|
+
last=$(cat "$w/alive" 2>/dev/null)
|
|
104
|
+
case "$last" in ''|*[!0-9]*) continue;; esac # not a time (being written): never read as 0, which is long ago
|
|
105
|
+
age=$(( $(date +%s) - last ))
|
|
106
|
+
if ps -ww -p "$pid" -o command= 2>/dev/null | grep -qF -- "$w/request.json"; then
|
|
107
|
+
[ "$age" -gt "$lost" ] || continue
|
|
108
|
+
kill -s TERM -- "-$pid" 2>/dev/null; sleep 5; kill -s KILL -- "-$pid" 2>/dev/null
|
|
109
|
+
elif [ "$age" -le "$keep" ]; then
|
|
110
|
+
continue
|
|
111
|
+
fi
|
|
112
|
+
rm -rf "$w"
|
|
113
|
+
rmdir "${w%/*}" "${w%/*/*}" 2>/dev/null # the run's folders too, once empty (as fetch does)
|
|
114
|
+
exit 0
|
|
115
|
+
done"""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def heartbeat_sh(attempt: str) -> str:
|
|
119
|
+
"""sh that writes the heartbeat the watchdog reads, in one step (written beside it, then renamed): `date > alive`
|
|
120
|
+
empties the file first, and a watchdog reading it then would see no time at all."""
|
|
121
|
+
a = shlex.quote(attempt)
|
|
122
|
+
return f"date +%s > {a}/alive.tmp 2>/dev/null && mv -f {a}/alive.tmp {a}/alive 2>/dev/null"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def removal_at_boot(at: int, start: str) -> str:
|
|
126
|
+
"""A pod's start command: its removal at `at` (a Unix time), set before anything else runs on it and asked again
|
|
127
|
+
until RunPod does it (runpodctl, with the pod's own key, scoped to that pod), then the image's own start (sshd).
|
|
128
|
+
The backstop for a pod whose driver never comes back, from its first moment; perception-deadline names it (guard.sh)."""
|
|
129
|
+
# asked every 30 s until it happens, whatever runpodctl's exit says: the pod's removal ends this loop with it
|
|
130
|
+
removal = (f"until [ $(date +%s) -ge {int(at)} ]; do sleep 15; done; "
|
|
131
|
+
'while :; do RUNPOD_NO_UPDATE_CHECK=1 runpodctl pod delete "$RUNPOD_POD_ID"; sleep 30; done')
|
|
132
|
+
return f"nohup sh -c {shlex.quote(removal)} perception-deadline > /dev/null 2>&1 & exec {start}"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def say(msg: str):
|
|
136
|
+
print(f"[remote] {msg}", flush=True)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class Driver:
|
|
140
|
+
def __init__(self, request_path: Path, spec: dict):
|
|
141
|
+
self.request_path, self.spec = request_path, spec
|
|
142
|
+
self.local = request_path.parent
|
|
143
|
+
self.request = json.loads(request_path.read_text())
|
|
144
|
+
self.pid: int | None = None
|
|
145
|
+
self.uploading = False # an upload to the host began: discard_incoming has something to look for
|
|
146
|
+
self.fetching = False # the node finished and its results are coming back: within the deadline's margin
|
|
147
|
+
self.unreachable_since: float | None = None
|
|
148
|
+
self.started = time.monotonic()
|
|
149
|
+
self.stop_at: float | None = None # a cloud machine's deadline, less the time to bring results back and delete it
|
|
150
|
+
self.extra_usd = 0.0
|
|
151
|
+
self.phases: dict[str, float] = {} # seconds each part of the attempt took (in the response's usage, and the log)
|
|
152
|
+
self.deadline_set = False # the machine's own OS shutdown is scheduled (guard.sh): it cannot outlive its deadline
|
|
153
|
+
self.launch_unstopped = False # a launch whose cancel on SkyPilot's server was not confirmed: it may still make a machine
|
|
154
|
+
# a RunPod pod: its id once created, the price RunPod gave it, and whether a create was sent without an answer
|
|
155
|
+
self.pod: str | None = None
|
|
156
|
+
self.pod_name: str | None = None
|
|
157
|
+
self.pod_usd: float | None = None
|
|
158
|
+
self.pod_unknown = False
|
|
159
|
+
# the account key, for this process alone: out of the environment its ssh and rsync commands inherit
|
|
160
|
+
self.runpod_key = os.environ.pop("RUNPOD_API_KEY", None)
|
|
161
|
+
self.at(spec["dest"], spec["root"])
|
|
162
|
+
|
|
163
|
+
def at(self, dest: str, root: str):
|
|
164
|
+
self.dest, self.root = dest, root
|
|
165
|
+
r = self.request
|
|
166
|
+
self.attempt = f"{root}/work/{r['run']}/{r['node']}/{r['attempt']}"
|
|
167
|
+
self.node_runs = f"{root}/work/{r['run']}/{r['node']}"
|
|
168
|
+
self.code = f"{root}/code/{self.spec['code']}"
|
|
169
|
+
self.tag = f"{r['run']}-{r['node']}-{r['attempt']}" # this attempt's own names for what it uploads
|
|
170
|
+
|
|
171
|
+
# --- commands on the host -----------------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
def sh(self, script: str, check: bool = True, data: bytes | None = None, timeout: float = 600) -> subprocess.CompletedProcess:
|
|
174
|
+
"""Run a POSIX sh script on the host (through sh: the login shell may be zsh or fish). ssh's own failures (exit
|
|
175
|
+
255: no connection) raise Unreachable."""
|
|
176
|
+
out = subprocess.run([*self.spec["ssh"], self.dest, f"sh -c {shlex.quote(script)}"], input=data, capture_output=True, timeout=timeout)
|
|
177
|
+
if out.returncode == 255:
|
|
178
|
+
raise Unreachable(out.stderr.decode(errors="replace").strip()[-300:])
|
|
179
|
+
if check and out.returncode in REFUSALS:
|
|
180
|
+
raise Refused(REFUSALS[out.returncode], out.stderr.decode(errors="replace").strip()[-500:])
|
|
181
|
+
if check and out.returncode:
|
|
182
|
+
raise RuntimeError(f"on {self.spec['host']}: {script[:120]}... exited {out.returncode}: "
|
|
183
|
+
f"{out.stderr.decode(errors='replace').strip()[-500:]}")
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
def rsync(self, *args: str):
|
|
187
|
+
shell = " ".join(shlex.quote(a) for a in self.spec["ssh"])
|
|
188
|
+
# -rlt: files, folders, links as links (never followed: a link in out/ is refused at publication) and times;
|
|
189
|
+
# no devices, permissions or owners
|
|
190
|
+
out = subprocess.run(["rsync", "-rlt", "-e", shell, *args], capture_output=True, text=True, timeout=24 * 3600)
|
|
191
|
+
if out.returncode:
|
|
192
|
+
raise RuntimeError(f"rsync {' '.join(args[-2:])} failed ({out.returncode}): {out.stderr.strip()[-500:]}")
|
|
193
|
+
|
|
194
|
+
# --- steps ----------------------------------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def check_root(self):
|
|
197
|
+
"""The root must still be the user's own private folder: the markers below are trusted only because no one
|
|
198
|
+
else can write there (hosts.PRIVATE_ROOT)."""
|
|
199
|
+
from .hosts import private_root_script
|
|
200
|
+
self.sh(private_root_script(self.root))
|
|
201
|
+
|
|
202
|
+
def send_code(self):
|
|
203
|
+
"""The code under code/<its hash>, published in one step (PUBLISH): runs on the host at the same time may send
|
|
204
|
+
the same code. What is sent is a copy taken here and checked against that hash first: a file saved while the
|
|
205
|
+
code is on its way must not be filed under the hash of the code before it."""
|
|
206
|
+
import shutil
|
|
207
|
+
from .backends.native import REPO
|
|
208
|
+
from .backends.remote import code_hash
|
|
209
|
+
q = shlex.quote
|
|
210
|
+
if self.sh(f"test -e {q(self.code)}/.complete", check=False).returncode == 0:
|
|
211
|
+
return
|
|
212
|
+
snapshot = self.local / "code"
|
|
213
|
+
shutil.rmtree(snapshot, ignore_errors=True)
|
|
214
|
+
for f in self.spec["files"]:
|
|
215
|
+
(snapshot / f).parent.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
shutil.copy2(REPO / f, snapshot / f)
|
|
217
|
+
if code_hash(self.spec["files"], snapshot) != self.spec["code"]:
|
|
218
|
+
shutil.rmtree(snapshot)
|
|
219
|
+
raise RuntimeError("the node's code changed since this attempt started: nothing was sent (the next attempt sends the new code)")
|
|
220
|
+
say(f"copying the node's code to {self.spec['host']}")
|
|
221
|
+
incoming = f"{self.root}/code/.incoming-{self.tag}"
|
|
222
|
+
self.uploading = True
|
|
223
|
+
self.sh(f"rm -rf {q(incoming)} && mkdir -p {q(incoming)}")
|
|
224
|
+
listing = self.local / "code-files.txt"
|
|
225
|
+
listing.write_text("".join(f + "\n" for f in self.spec["files"]))
|
|
226
|
+
self.rsync("--files-from", str(listing), f"{snapshot}/", f"{self.dest}:{incoming}/")
|
|
227
|
+
self.sh(f"{PUBLISH}\ntouch {q(incoming)}/.complete && publish {q(incoming)} {q(self.code)}")
|
|
228
|
+
shutil.rmtree(snapshot)
|
|
229
|
+
|
|
230
|
+
def send_inputs(self) -> dict[str, dict]:
|
|
231
|
+
"""Each input as objects/<sha256>/data on the host, published in one step (PUBLISH): nodes on the host at the
|
|
232
|
+
same time may send the same input. The node gets it under its own name, a link in its attempt's in/ (the same
|
|
233
|
+
bytes can arrive under another name: the video renamed)."""
|
|
234
|
+
q = shlex.quote
|
|
235
|
+
out = {}
|
|
236
|
+
for name, entry in self.request["inputs"].items():
|
|
237
|
+
# the content, not a link to it: a graph input is often a link (to a video on an external drive), which rsync
|
|
238
|
+
# would send as a link pointing nowhere there (or somewhere else). The node gets it under the name it has
|
|
239
|
+
# here, the link's own, as a local run gives it (decode records it): the target's name never goes there.
|
|
240
|
+
given = Path(entry["path"])
|
|
241
|
+
local = given.resolve()
|
|
242
|
+
folder = f"{self.root}/objects/{entry['sha256']}"
|
|
243
|
+
out[name] = {**entry, "path": f"{self.attempt}/in/{name}/{given.name}", "object": f"{folder}/data"}
|
|
244
|
+
if self.sh(f"test -e {q(folder)}/.complete", check=False).returncode == 0:
|
|
245
|
+
continue
|
|
246
|
+
size = sum(p.stat().st_size for p in local.rglob("*") if p.is_file()) if local.is_dir() else local.stat().st_size
|
|
247
|
+
say(f"copying {name} ({size / 1e6:.1f} MB) to {self.spec['host']}")
|
|
248
|
+
incoming = f"{self.root}/objects/.incoming-{self.tag}-{name}"
|
|
249
|
+
self.uploading = True
|
|
250
|
+
self.sh(f"rm -rf {q(incoming)} && mkdir -p {q(incoming)}")
|
|
251
|
+
self.rsync(f"{local}/" if local.is_dir() else str(local), f"{self.dest}:{incoming}/data" + ("/" if local.is_dir() else ""))
|
|
252
|
+
# filed under its sha256 only if that is what arrived (a file changed since the run hashed it is refused);
|
|
253
|
+
# a folder comes from the store, which checked it when it was published
|
|
254
|
+
verify = "" if local.is_dir() else (
|
|
255
|
+
f"got=$( (sha256sum {q(incoming)}/data 2>/dev/null || shasum -a 256 {q(incoming)}/data) | cut -d' ' -f1)\n"
|
|
256
|
+
f"[ \"$got\" = {q(entry['sha256'])} ] || {{ rm -rf {q(incoming)}; echo \"{name} arrived as $got, not {entry['sha256']}: "
|
|
257
|
+
f"it changed since the run read it\" >&2; exit 5; }}\n")
|
|
258
|
+
# read-only once published, as the local store's objects are: a node that wrote to its input (through its
|
|
259
|
+
# link) would change it for every later run on this host, under the same sha256
|
|
260
|
+
self.sh(f"{PUBLISH}\n{verify}touch {q(incoming)}/.complete && publish {q(incoming)} {q(folder)} && chmod -R a-w {q(folder)}")
|
|
261
|
+
return out
|
|
262
|
+
|
|
263
|
+
def start(self, inputs: dict[str, dict]):
|
|
264
|
+
q, s = shlex.quote, self.spec
|
|
265
|
+
request = {**self.request, "inputs": {k: {x: y for x, y in v.items() if x != "object"} for k, v in inputs.items()},
|
|
266
|
+
"out_dir": f"{self.attempt}/out"}
|
|
267
|
+
if s["runner_kind"] == "self":
|
|
268
|
+
node = f"{q(s['python'])} {q(s['entry'])}"
|
|
269
|
+
else:
|
|
270
|
+
node = f"{q(s['uv'])} run --project {q(self.code + '/' + s['env'])} --frozen --no-dev python {q(s['entry'])}"
|
|
271
|
+
req = f"{self.attempt}/request.json"
|
|
272
|
+
path = ":".join(s["path"])
|
|
273
|
+
links = "\n".join(f"mkdir -p {q(self.attempt)}/in/{q(name)} && ln -sfn {q(v['object'])} {q(v['path'])}"
|
|
274
|
+
for name, v in inputs.items())
|
|
275
|
+
lost = int(s["lost_after_s"] + min(60, s["lost_after_s"])) # after the driver has given up
|
|
276
|
+
every = max(1, min(30, lost // 4))
|
|
277
|
+
# an earlier attempt of this node that a lost connection left running would hold the GPU: stop it first
|
|
278
|
+
script = f"""
|
|
279
|
+
set -e
|
|
280
|
+
for f in {q(self.node_runs)}/*/pid; do
|
|
281
|
+
[ -f "$f" ] || continue
|
|
282
|
+
old=$(cat "$f")
|
|
283
|
+
# only if that pid is still that attempt's node (pids are reused on a shared machine)
|
|
284
|
+
if ps -ww -p "$old" -o command= 2>/dev/null | grep -qF -- "${{f%/pid}}/request.json"; then
|
|
285
|
+
kill -s TERM -- "-$old" 2>/dev/null || true
|
|
286
|
+
n=0; while [ $n -lt 10 ] && kill -0 "$old" 2>/dev/null; do sleep 1; n=$((n + 1)); done # gone before this one starts
|
|
287
|
+
kill -s KILL -- "-$old" 2>/dev/null || true
|
|
288
|
+
fi
|
|
289
|
+
done
|
|
290
|
+
mkdir -p {q(self.attempt)}/out
|
|
291
|
+
{links}
|
|
292
|
+
cat > {q(req)}
|
|
293
|
+
{heartbeat_sh(self.attempt)}
|
|
294
|
+
cd {q(self.code + '/' + s['node_dir'])}
|
|
295
|
+
# its own session, so its whole process tree can be stopped (kill -s TERM -- -PID) and a closed connection does not
|
|
296
|
+
# reach it: setsid on Linux, perl's on a Mac (no setsid command there). Not a group leader in the background, so
|
|
297
|
+
# neither forks: $! stays the node's pid.
|
|
298
|
+
if command -v setsid >/dev/null 2>&1; then set -- setsid; else set -- perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV or die'; fi
|
|
299
|
+
PATH={q(path)}${{PATH:+:$PATH}} nohup "$@" {node} --request {q(req)} > {q(self.attempt)}/log.txt 2>&1 < /dev/null &
|
|
300
|
+
pid=$!
|
|
301
|
+
echo $pid > {q(self.attempt)}/pid
|
|
302
|
+
# a cancel that came while this script ran (and found no pid yet) left a stop mark: stop the node now
|
|
303
|
+
if [ -e {q(self.attempt)}/stop ]; then kill -s TERM -- "-$pid" 2>/dev/null || true; fi
|
|
304
|
+
nohup "$@" sh -c {q(WATCHDOG)} watchdog {q(self.attempt)} "$pid" {lost} {every} {KEEP_S} > /dev/null 2>&1 < /dev/null &
|
|
305
|
+
echo $pid
|
|
306
|
+
"""
|
|
307
|
+
out = self.sh(script, data=json.dumps(request, indent=1).encode())
|
|
308
|
+
self.pid = int(out.stdout.decode().split()[-1])
|
|
309
|
+
say(f"started on {s['host']} (pid {self.pid})")
|
|
310
|
+
|
|
311
|
+
def unreachable(self, e: Exception):
|
|
312
|
+
"""Wait out a host that cannot be reached, for up to lost_after_s in all since it was last reached; past that,
|
|
313
|
+
give up (raise). Not past a cloud machine's deadline: that is a failure of its own (Deadline)."""
|
|
314
|
+
now = time.monotonic()
|
|
315
|
+
if self.stop_at is not None and not self.fetching and now > self.stop_at:
|
|
316
|
+
raise Deadline()
|
|
317
|
+
if self.unreachable_since is None:
|
|
318
|
+
self.unreachable_since = now
|
|
319
|
+
say(f"{self.spec['host']} cannot be reached ({e}); waiting up to {self.spec['lost_after_s']:.0f} s")
|
|
320
|
+
elif now - self.unreachable_since > self.spec["lost_after_s"]:
|
|
321
|
+
raise Unreachable(str(e))
|
|
322
|
+
time.sleep(POLL_S)
|
|
323
|
+
|
|
324
|
+
def patiently(self, step, *args):
|
|
325
|
+
"""A step repeated while the host cannot be reached (up to lost_after_s, as watching waits): a short outage
|
|
326
|
+
must not cost an attempt, nor a finished node's results. A failed rsync is repeated only if the host is
|
|
327
|
+
unreachable; any other failure is the step's own."""
|
|
328
|
+
while True:
|
|
329
|
+
# an upload that ran into a cloud machine's deadline; never a fetch, which the deadline keeps a margin for
|
|
330
|
+
if self.stop_at is not None and not self.fetching and time.monotonic() > self.stop_at:
|
|
331
|
+
raise Deadline()
|
|
332
|
+
try:
|
|
333
|
+
result = step(*args)
|
|
334
|
+
self.unreachable_since = None
|
|
335
|
+
return result
|
|
336
|
+
except Unreachable as e:
|
|
337
|
+
self.unreachable(e)
|
|
338
|
+
except RuntimeError as e:
|
|
339
|
+
try:
|
|
340
|
+
self.sh("true")
|
|
341
|
+
except Unreachable:
|
|
342
|
+
self.unreachable(e)
|
|
343
|
+
continue
|
|
344
|
+
raise
|
|
345
|
+
|
|
346
|
+
@contextlib.contextmanager
|
|
347
|
+
def heartbeat(self):
|
|
348
|
+
"""Keep writing the heartbeat while something long happens (a large out/ coming back), so the host's watchdog
|
|
349
|
+
does not take this attempt for one no driver is watching."""
|
|
350
|
+
done = threading.Event()
|
|
351
|
+
every = min(POLL_S, max(0.2, self.spec["lost_after_s"] / 2))
|
|
352
|
+
|
|
353
|
+
def beat():
|
|
354
|
+
while not done.wait(every):
|
|
355
|
+
try:
|
|
356
|
+
self.sh(heartbeat_sh(self.attempt), check=False)
|
|
357
|
+
except (Unreachable, subprocess.TimeoutExpired):
|
|
358
|
+
pass # a dropped connection: the step itself waits it out
|
|
359
|
+
thread = threading.Thread(target=beat, daemon=True)
|
|
360
|
+
thread.start()
|
|
361
|
+
try:
|
|
362
|
+
yield
|
|
363
|
+
finally:
|
|
364
|
+
done.set()
|
|
365
|
+
thread.join()
|
|
366
|
+
|
|
367
|
+
def watch(self):
|
|
368
|
+
q = shlex.quote
|
|
369
|
+
req, log = f"{self.attempt}/request.json", f"{self.attempt}/log.txt"
|
|
370
|
+
offset = 0
|
|
371
|
+
while True:
|
|
372
|
+
# the heartbeat first: the host's watchdog ends a node no driver has asked about for too long
|
|
373
|
+
check = (f"{heartbeat_sh(self.attempt)}; "
|
|
374
|
+
f"if kill -0 {self.pid} 2>/dev/null && ps -ww -p {self.pid} -o command= | grep -qF -- {q(req)}; "
|
|
375
|
+
f"then echo running; else echo exited; fi; tail -c +{offset + 1} {q(log)} 2>/dev/null || true")
|
|
376
|
+
try:
|
|
377
|
+
out = self.sh(check).stdout
|
|
378
|
+
self.unreachable_since = None
|
|
379
|
+
except Unreachable as e:
|
|
380
|
+
self.unreachable(e)
|
|
381
|
+
continue
|
|
382
|
+
state, _, text = out.partition(b"\n")
|
|
383
|
+
if text:
|
|
384
|
+
sys.stdout.buffer.write(text)
|
|
385
|
+
sys.stdout.flush()
|
|
386
|
+
offset += len(text)
|
|
387
|
+
if state.strip() == b"exited":
|
|
388
|
+
return
|
|
389
|
+
if self.stop_at is not None and time.monotonic() > self.stop_at:
|
|
390
|
+
raise Deadline()
|
|
391
|
+
time.sleep(POLL_S)
|
|
392
|
+
|
|
393
|
+
def fetch(self):
|
|
394
|
+
"""out/ and response.json, into this attempt's folder. No response there: the node crashed (none is written
|
|
395
|
+
here either, and the coordinator sees a crash). The node has finished: a dropped connection is waited out, and
|
|
396
|
+
the heartbeat kept up, rather than its results lost."""
|
|
397
|
+
q = shlex.quote
|
|
398
|
+
self.fetching = True
|
|
399
|
+
with self.heartbeat():
|
|
400
|
+
# never through a link the node made: rsync would copy what `out/` points at, which publication cannot see
|
|
401
|
+
listing = self.patiently(self.sh, f"cd {q(self.attempt)} 2>/dev/null || exit 0; [ -d out ] && [ ! -L out ] && echo out; "
|
|
402
|
+
f"[ -f response.json ] && [ ! -L response.json ] && echo response.json; true").stdout.decode().split()
|
|
403
|
+
if "out" in listing:
|
|
404
|
+
self.patiently(self.rsync, f"{self.dest}:{self.attempt}/out/", f"{self.local}/out/")
|
|
405
|
+
if "response.json" in listing:
|
|
406
|
+
self.patiently(self.rsync, f"{self.dest}:{self.attempt}/response.json", f"{self.local}/response.json.part")
|
|
407
|
+
# the run's folders too once empty (another node of the run may still be using them)
|
|
408
|
+
self.patiently(self.sh, f"rm -rf {q(self.attempt)}; rmdir {q(self.node_runs)} {q(self.node_runs.rsplit('/', 1)[0])} 2>/dev/null || true")
|
|
409
|
+
|
|
410
|
+
def prepare(self):
|
|
411
|
+
"""What `perception setup` does for a node here, done on the host before it starts: its uv environment synced from
|
|
412
|
+
its lock, then its prepare script (its weights) run in it with its settings. A failure is the attempt's own and
|
|
413
|
+
final (PrepareFailed: not retried, since it would most likely fail again, at a cloud machine's cost)."""
|
|
414
|
+
q, s = shlex.quote, self.spec
|
|
415
|
+
path = ":".join(s["path"])
|
|
416
|
+
node_dir = f"{self.code}/{s['node_dir']}"
|
|
417
|
+
if s["runner_kind"] == "self":
|
|
418
|
+
python = q(s["python"])
|
|
419
|
+
sync = ""
|
|
420
|
+
else:
|
|
421
|
+
project = q(f"{self.code}/{s['env']}")
|
|
422
|
+
python = f"{q(s['uv'])} run --project {project} --frozen --no-dev python"
|
|
423
|
+
sync = f"{q(s['uv'])} sync --project {project} --frozen --no-dev"
|
|
424
|
+
steps = [x for x in (sync, f"{python} {q(s['prepare'])} {q(json.dumps(self.request.get('config', {})))}" if s.get("prepare") else "") if x]
|
|
425
|
+
if not steps:
|
|
426
|
+
return
|
|
427
|
+
say("preparing the node there (its environment, its weights)")
|
|
428
|
+
# within a cloud machine's deadline: past it, the node could not run anyway
|
|
429
|
+
limit = 3600 if self.stop_at is None else self.stop_at - time.monotonic()
|
|
430
|
+
if limit <= 0:
|
|
431
|
+
raise Deadline()
|
|
432
|
+
try:
|
|
433
|
+
done = self.patiently(self.sh, f"cd {q(node_dir)} && export PATH={q(path)}${{PATH:+:$PATH}} && " + " && ".join(steps),
|
|
434
|
+
False, None, limit)
|
|
435
|
+
except subprocess.TimeoutExpired:
|
|
436
|
+
if self.stop_at is not None:
|
|
437
|
+
raise Deadline()
|
|
438
|
+
raise PrepareFailed("preparing the node took more than an hour")
|
|
439
|
+
sys.stdout.buffer.write(done.stdout[-4000:] + done.stderr[-4000:])
|
|
440
|
+
if done.returncode:
|
|
441
|
+
raise PrepareFailed(f"preparing the node failed (exit {done.returncode}): see the end of the log")
|
|
442
|
+
|
|
443
|
+
def stop(self):
|
|
444
|
+
"""Stop this attempt's node on the host, if it started (by its pid file: the driver may have been canceled
|
|
445
|
+
between starting it and reading its pid)."""
|
|
446
|
+
signal.signal(signal.SIGTERM, signal.SIG_IGN) # a second cancel must not interrupt the stopping
|
|
447
|
+
q = shlex.quote
|
|
448
|
+
req, pidfile = f"{self.attempt}/request.json", f"{self.attempt}/pid"
|
|
449
|
+
say(f"stopping it on {self.spec['host']}")
|
|
450
|
+
try:
|
|
451
|
+
# the mark first, then the pid: a start still running on the host sees the one, or this finds the other
|
|
452
|
+
self.sh(f'mkdir -p {q(self.attempt)} && touch {q(self.attempt)}/stop; [ -f {q(pidfile)} ] || exit 0; p=$(cat {q(pidfile)}); '
|
|
453
|
+
f'if ps -ww -p "$p" -o command= | grep -qF -- {q(req)}; then kill -s TERM -- "-$p"; fi', check=False)
|
|
454
|
+
except (Unreachable, subprocess.TimeoutExpired) as e:
|
|
455
|
+
say(f"could not reach {self.spec['host']} to stop it ({e}); it ends when the next attempt of this node starts there")
|
|
456
|
+
|
|
457
|
+
@contextlib.contextmanager
|
|
458
|
+
def phase(self, name: str):
|
|
459
|
+
"""Time a part of the attempt (launch, setup, code, inputs, run, fetch, teardown): where its time goes."""
|
|
460
|
+
start = time.monotonic()
|
|
461
|
+
try:
|
|
462
|
+
yield
|
|
463
|
+
finally:
|
|
464
|
+
self.phases[name] = round(self.phases.get(name, 0.0) + time.monotonic() - start, 1)
|
|
465
|
+
|
|
466
|
+
def publish_response(self):
|
|
467
|
+
"""The node's response, now that it is final: with what the cloud machine cost added to what the node spent, and
|
|
468
|
+
where the attempt's time went (usage.phases; `startup` is the run's time outside the node's own work: its imports,
|
|
469
|
+
and the driver's ssh round trips and polling)."""
|
|
470
|
+
if self.phases:
|
|
471
|
+
say("time: " + ", ".join(f"{k} {v:.0f} s" for k, v in self.phases.items()))
|
|
472
|
+
part = self.local / "response.json.part"
|
|
473
|
+
if not part.exists():
|
|
474
|
+
return
|
|
475
|
+
response = json.loads(part.read_text())
|
|
476
|
+
usage = response.setdefault("usage", {})
|
|
477
|
+
if self.extra_usd:
|
|
478
|
+
usage["usd"] = round(float(usage.get("usd", 0.0)) + self.extra_usd, 4)
|
|
479
|
+
usage["machine_usd"] = self.extra_usd
|
|
480
|
+
phases = dict(self.phases)
|
|
481
|
+
if "run" in phases and isinstance(usage.get("seconds"), (int, float)):
|
|
482
|
+
phases["startup"] = round(max(0.0, phases["run"] - usage["seconds"]), 1)
|
|
483
|
+
usage["phases"] = phases
|
|
484
|
+
part.write_text(json.dumps(response, indent=1))
|
|
485
|
+
part.replace(self.local / "response.json")
|
|
486
|
+
|
|
487
|
+
# --- a cloud machine ------------------------------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
def cloud_env(self, c: dict) -> dict:
|
|
490
|
+
return {**os.environ, "SKYPILOT_PROJECT_CONFIG": c["project_config"]}
|
|
491
|
+
|
|
492
|
+
def provision(self, c: dict):
|
|
493
|
+
"""Start the machine. Its own deadline is set from the first moment, in case this laptop never comes back: the
|
|
494
|
+
task's setup schedules the OS shutdown first (DEADLINE_EPOCH), and while the launch runs a watcher makes an OS
|
|
495
|
+
shutdown terminate the instance (EC2 would only stop it, its disk billing). The launch has a time limit
|
|
496
|
+
(launch_s). SkyPilot runs a launch on its API server, where it goes on if its client is stopped: so it is
|
|
497
|
+
submitted (--async), followed by its id, and on a timeout or a cancel canceled there before the machine is
|
|
498
|
+
deleted."""
|
|
499
|
+
with self.phase("launch"):
|
|
500
|
+
if c.get("provider") == "runpod":
|
|
501
|
+
self.runpod_launch(c)
|
|
502
|
+
else:
|
|
503
|
+
self.launch(c)
|
|
504
|
+
with self.phase("setup"):
|
|
505
|
+
self.set_up(c)
|
|
506
|
+
|
|
507
|
+
def launch(self, c: dict):
|
|
508
|
+
from .hosts import HostError, probe
|
|
509
|
+
say(f"starting a cloud machine, {c['cluster']} (at most {c['deadline_s'] / 60:.0f} min at ${c['hourly_usd']}/h)")
|
|
510
|
+
# a moment, not a delay: SkyPilot runs that setup detached, perhaps after guard.sh, and must not move the deadline on
|
|
511
|
+
# (a minute early: the machine's clock may be up to a minute behind this one's, which is checked)
|
|
512
|
+
deadline_at = int(time.time() + c["deadline_s"] - (time.monotonic() - self.started)) - 60
|
|
513
|
+
request = None
|
|
514
|
+
try:
|
|
515
|
+
submitted = subprocess.run([c["sky"], "launch", "--async", "-c", c["cluster"], "-y", "--down", "-i", "15",
|
|
516
|
+
"--env", f"DEADLINE_EPOCH={deadline_at}", c["task"]], env=self.cloud_env(c),
|
|
517
|
+
capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=600)
|
|
518
|
+
sys.stdout.write(submitted.stdout + submitted.stderr)
|
|
519
|
+
sys.stdout.flush()
|
|
520
|
+
found = re.search(r"request: ([0-9a-f]{8}-[0-9a-f-]{27,})", re.sub(r"\x1b\[[0-9;]*m", "", submitted.stdout))
|
|
521
|
+
if submitted.returncode or not found:
|
|
522
|
+
raise LaunchFailed(f"sky launch was not submitted, or did not say its request (exit {submitted.returncode})")
|
|
523
|
+
request = found.group(1)
|
|
524
|
+
except FileNotFoundError:
|
|
525
|
+
raise Refused("input", f"SkyPilot is not installed here ({c['sky']}): perception cloud on checks for it")
|
|
526
|
+
finally:
|
|
527
|
+
if request is None: # interrupted, timed out or not understood: it may still have been submitted
|
|
528
|
+
self.cancel_launches(c)
|
|
529
|
+
logs = subprocess.Popen([c["sky"], "api", "logs", request], env=self.cloud_env(c), stdout=sys.stdout,
|
|
530
|
+
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True)
|
|
531
|
+
terminating, finished = False, False
|
|
532
|
+
try:
|
|
533
|
+
while True:
|
|
534
|
+
state = self.request_status(c, request)
|
|
535
|
+
if state == "SUCCEEDED":
|
|
536
|
+
finished = True
|
|
537
|
+
break
|
|
538
|
+
if state in ("FAILED", "CANCELLED"):
|
|
539
|
+
finished = True
|
|
540
|
+
raise LaunchFailed(f"sky launch {state.lower()}")
|
|
541
|
+
if not terminating:
|
|
542
|
+
terminating = self.terminate_on_shutdown(c, quiet=True)
|
|
543
|
+
if time.monotonic() - self.started > c["launch_s"]:
|
|
544
|
+
raise LaunchFailed(f"the machine was not ready after {c['launch_s'] / 60:.0f} min (sky launch canceled)")
|
|
545
|
+
time.sleep(POLL_S)
|
|
546
|
+
finally:
|
|
547
|
+
if not finished: # a timeout or a cancel: stop the launch on the API server, or sky down could miss it
|
|
548
|
+
self.sky_cancel(c, [request])
|
|
549
|
+
logs.terminate()
|
|
550
|
+
logs.wait()
|
|
551
|
+
# again now that it is up: the launch may have replaced the instance it first created (another zone). A machine
|
|
552
|
+
# that would only stop at its deadline (its disk billing on) is not used.
|
|
553
|
+
if not self.terminate_on_shutdown(c):
|
|
554
|
+
raise Refused("internal", f"{c['cluster']} could not be set to terminate on shutdown (aws ec2 modify-instance-attribute): it is not used")
|
|
555
|
+
try:
|
|
556
|
+
caps, root = probe(c["cluster"])
|
|
557
|
+
except HostError as e:
|
|
558
|
+
raise LaunchFailed(f"{c['cluster']} started but could not be probed: {e}")
|
|
559
|
+
self.at(c["cluster"], root)
|
|
560
|
+
self.spec.update(uv=caps.tools.get("uv"), path=sorted({str(Path(t).parent) for t in caps.tools.values() if t}))
|
|
561
|
+
if "cuda" not in caps.accelerators:
|
|
562
|
+
raise Refused("resources", f"{c['cluster']} has no NVIDIA GPU that nvidia-smi can see")
|
|
563
|
+
# setup schedules the shutdown at the deadline as a moment by this laptop's clock: the machine's must agree
|
|
564
|
+
self.check_clock(c["cluster"])
|
|
565
|
+
# its OS shuts it down (and so terminates it) at the deadline, counted from the start of the launch
|
|
566
|
+
minutes = max(1, math.floor((c["deadline_s"] - (time.monotonic() - self.started)) / 60))
|
|
567
|
+
guard = Path(c["guard"]).read_bytes()
|
|
568
|
+
result = self.sh(f"bash -s {minutes}", check=False, data=guard)
|
|
569
|
+
sys.stdout.buffer.write(result.stdout + result.stderr)
|
|
570
|
+
if result.returncode:
|
|
571
|
+
raise GuardRefused(f"guard.sh refused {c['cluster']} (exit {result.returncode}): nothing was sent; see the log")
|
|
572
|
+
self.deadline_set = True
|
|
573
|
+
|
|
574
|
+
def check_clock(self, machine: str):
|
|
575
|
+
"""The machine's own deadline is a moment by this laptop's clock (a minute early): its clock must agree, to a minute."""
|
|
576
|
+
skew = int(self.sh("date +%s").stdout.decode().strip()) - time.time()
|
|
577
|
+
if abs(skew) > 60:
|
|
578
|
+
raise Refused("internal", f"{machine}'s clock and this machine's differ by {skew:.0f} s: set this machine's "
|
|
579
|
+
"clock (its deadline is a moment by it)")
|
|
580
|
+
|
|
581
|
+
def set_up(self, c: dict):
|
|
582
|
+
# what the node needs, installed now and waited for (SkyPilot's own setup runs detached: node-setup.sh)
|
|
583
|
+
machine = c.get("cluster") or f"pod {self.pod}"
|
|
584
|
+
say("installing ffmpeg, rsync and uv on the machine")
|
|
585
|
+
try:
|
|
586
|
+
done = self.sh(f"bash -s", check=False, data=Path(c["setup"]).read_bytes())
|
|
587
|
+
except subprocess.TimeoutExpired:
|
|
588
|
+
raise LaunchFailed(f"setting up {machine} took more than 10 min")
|
|
589
|
+
sys.stdout.buffer.write(done.stderr)
|
|
590
|
+
if done.returncode:
|
|
591
|
+
raise LaunchFailed(f"setting up {machine} failed (exit {done.returncode}): see the log")
|
|
592
|
+
uv = done.stdout.decode().strip().splitlines()[-1] if done.stdout.strip() else ""
|
|
593
|
+
self.spec["uv"] = uv or None
|
|
594
|
+
if not uv:
|
|
595
|
+
raise LaunchFailed(f"setting up {machine} did not say where uv is")
|
|
596
|
+
# 5 minutes to bring results back and delete it, and 3 more: setup's shutdown may come that much early (a minute
|
|
597
|
+
# set early, a minute rounding down, a minute of clock skew)
|
|
598
|
+
self.stop_at = self.started + c["deadline_s"] - min(480, c["deadline_s"] / 4)
|
|
599
|
+
|
|
600
|
+
def cancel_launches(self, c: dict):
|
|
601
|
+
"""Cancel, on SkyPilot's API server, any launch of this attempt's cluster still pending or running: found by its
|
|
602
|
+
cluster's name, which only this attempt uses (a launch whose request id was never read)."""
|
|
603
|
+
try:
|
|
604
|
+
out = subprocess.run([c["sky"], "api", "status", "-v", "-o", "json"], env=self.cloud_env(c), capture_output=True,
|
|
605
|
+
text=True, stdin=subprocess.DEVNULL, start_new_session=True, timeout=120)
|
|
606
|
+
except subprocess.TimeoutExpired:
|
|
607
|
+
self.launch_unstopped = True # not known to be stopped: teardown treats it as able to make a machine still
|
|
608
|
+
say(f"WARNING: SkyPilot did not list its requests in time to cancel a launch of {c['cluster']}; sky down follows")
|
|
609
|
+
return
|
|
610
|
+
try:
|
|
611
|
+
ids = [r["request_id"] for r in json.loads(out.stdout[out.stdout.index("["):])
|
|
612
|
+
if r.get("name") == "sky.launch" and r.get("cluster_name") == c["cluster"] and r.get("status") in ("PENDING", "RUNNING")]
|
|
613
|
+
except (ValueError, KeyError, TypeError):
|
|
614
|
+
self.launch_unstopped = True
|
|
615
|
+
say(f"WARNING: could not list SkyPilot's requests to cancel a launch of {c['cluster']}; sky down follows")
|
|
616
|
+
return
|
|
617
|
+
if ids:
|
|
618
|
+
self.sky_cancel(c, ids)
|
|
619
|
+
|
|
620
|
+
def sky_cancel(self, c: dict, requests: list[str]):
|
|
621
|
+
"""Cancel launch requests on SkyPilot's API server, within a time limit: a server that hangs must not keep the
|
|
622
|
+
machine from being deleted (teardown follows whatever happens here)."""
|
|
623
|
+
try:
|
|
624
|
+
code = subprocess.run([c["sky"], "api", "cancel", "-y", *requests], env=self.cloud_env(c), stdout=sys.stdout,
|
|
625
|
+
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True,
|
|
626
|
+
timeout=c.get("aws_s", 120)).returncode
|
|
627
|
+
except (subprocess.TimeoutExpired, OSError) as e:
|
|
628
|
+
code = str(e)
|
|
629
|
+
if code != 0:
|
|
630
|
+
self.launch_unstopped = True
|
|
631
|
+
say(f"WARNING: could not cancel the launch on SkyPilot's server ({code}); deleting the machine follows")
|
|
632
|
+
|
|
633
|
+
def request_status(self, c: dict, request: str) -> str:
|
|
634
|
+
"""A request's status on SkyPilot's API server (PENDING, RUNNING, SUCCEEDED, FAILED, CANCELLED), or "" when it
|
|
635
|
+
could not be read this time."""
|
|
636
|
+
try:
|
|
637
|
+
out = subprocess.run([c["sky"], "api", "status", "-a", request, "-o", "json"], env=self.cloud_env(c),
|
|
638
|
+
capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=120)
|
|
639
|
+
except subprocess.TimeoutExpired:
|
|
640
|
+
return "" # not read this time: the launch's own time limit still applies
|
|
641
|
+
try:
|
|
642
|
+
text = out.stdout[out.stdout.index("["):]
|
|
643
|
+
return next((r["status"] for r in json.loads(text) if r["request_id"] == request), "")
|
|
644
|
+
except (ValueError, KeyError, TypeError):
|
|
645
|
+
return ""
|
|
646
|
+
|
|
647
|
+
def terminate_on_shutdown(self, c: dict, quiet: bool = False) -> bool:
|
|
648
|
+
"""EC2 only stops an on-demand machine on an OS shutdown (its disk keeps billing): make it terminate, so its
|
|
649
|
+
deadline deletes it (as launch.sh does). Whether its instance was found and changed; `quiet` while the launch is
|
|
650
|
+
still creating it."""
|
|
651
|
+
aws = [c["aws"], "--region", c["region"], "--output", "text"]
|
|
652
|
+
ids = subprocess.run([*aws, "ec2", "describe-instances", "--query", "Reservations[].Instances[].InstanceId",
|
|
653
|
+
"--filters", f"Name=tag:skypilot-cluster-name,Values={c['cluster']},{c['cluster']}-*",
|
|
654
|
+
"Name=instance-state-name,Values=pending,running"], capture_output=True, text=True)
|
|
655
|
+
found = ids.stdout.split() if ids.returncode == 0 else []
|
|
656
|
+
done_all = bool(found)
|
|
657
|
+
for instance in found:
|
|
658
|
+
done = subprocess.run([*aws, "ec2", "modify-instance-attribute", "--instance-id", instance,
|
|
659
|
+
"--instance-initiated-shutdown-behavior", "terminate"], capture_output=True, text=True)
|
|
660
|
+
done_all &= done.returncode == 0
|
|
661
|
+
say(f"{instance} terminates on OS shutdown" if done.returncode == 0 else
|
|
662
|
+
f"WARNING: could not make {instance} terminate on shutdown ({done.stderr.strip()[-200:]}); its deadline only stops it")
|
|
663
|
+
if not found and not quiet:
|
|
664
|
+
say(f"WARNING: no instance found for {c['cluster']} ({ids.stderr.strip()[-200:]}); its deadline only stops it")
|
|
665
|
+
return done_all
|
|
666
|
+
|
|
667
|
+
def teardown(self, c: dict):
|
|
668
|
+
signal.signal(signal.SIGTERM, signal.SIG_IGN) # deleting the machine must not be interrupted
|
|
669
|
+
say(f"deleting {c['cluster']}")
|
|
670
|
+
try: # a limit too: a SkyPilot server that hangs must not hold the run
|
|
671
|
+
code = subprocess.run([c["sky"], "down", "-y", c["cluster"]], env=self.cloud_env(c), stdout=sys.stdout,
|
|
672
|
+
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True,
|
|
673
|
+
timeout=c.get("down_s", 900)).returncode # its own session: a cancel must not stop it
|
|
674
|
+
except subprocess.TimeoutExpired:
|
|
675
|
+
code = "timed out"
|
|
676
|
+
if code == 0 and self.launch_unstopped:
|
|
677
|
+
# sky down says "not found" (exit 0) for a cluster a launch has not made yet: that proves nothing either
|
|
678
|
+
code = "the launch was not stopped"
|
|
679
|
+
if code != 0: # SkyPilot could not delete it: end the instance through EC2 directly
|
|
680
|
+
say(f"sky down: {code}; terminating {c['cluster']}'s instances through EC2")
|
|
681
|
+
# no instance found proves nothing while a launch that was not stopped may still make one
|
|
682
|
+
code = 0 if self.terminate_instances(c) and not self.launch_unstopped else code
|
|
683
|
+
seconds = time.monotonic() - self.started
|
|
684
|
+
if code == 0: # its time; never more than its deadline once its OS shutdown was set (it cannot outlive it then)
|
|
685
|
+
self.extra_usd = round(c["hourly_usd"] * (min(seconds, c["deadline_s"]) if self.deadline_set else seconds) / 3600, 4)
|
|
686
|
+
elif self.deadline_set: # it lives until its deadline at most (guard.sh), and is charged that
|
|
687
|
+
self.extra_usd = round(c["hourly_usd"] * c["deadline_s"] / 3600, 4)
|
|
688
|
+
say(f"WARNING: could not delete {c['cluster']}: run `sky down {c['cluster']}`; otherwise it shuts down at its deadline")
|
|
689
|
+
else: # no deadline on it either: nothing ends it but someone; no other attempt starts another one
|
|
690
|
+
self.extra_usd = round(c["hourly_usd"] * c["deadline_s"] / 3600, 4)
|
|
691
|
+
message = (f"{c['cluster']} could not be deleted and has no shutdown deadline (or its launch could not be stopped): "
|
|
692
|
+
"it may still be running, or still be created. "
|
|
693
|
+
f"Run `sky down {c['cluster']}`, or terminate its instance in the AWS console ({c['region']})")
|
|
694
|
+
say(f"WARNING: {message}")
|
|
695
|
+
self.fail("internal", message)
|
|
696
|
+
say(f"the machine cost about ${self.extra_usd:.2f}" + ("" if code == 0 else " (at least)"))
|
|
697
|
+
|
|
698
|
+
def terminate_instances(self, c: dict) -> bool:
|
|
699
|
+
"""Terminate every instance of this attempt's cluster through EC2 (when SkyPilot could not): whether none is left.
|
|
700
|
+
An AWS call that fails or does not answer in time counts as not terminated (teardown then says so)."""
|
|
701
|
+
aws = [c["aws"], "--region", c["region"], "--output", "text"]
|
|
702
|
+
limit = c.get("aws_s", 120)
|
|
703
|
+
try:
|
|
704
|
+
ids = subprocess.run([*aws, "ec2", "describe-instances", "--query", "Reservations[].Instances[].InstanceId",
|
|
705
|
+
"--filters", f"Name=tag:skypilot-cluster-name,Values={c['cluster']},{c['cluster']}-*",
|
|
706
|
+
"Name=instance-state-name,Values=pending,running,stopping,stopped"],
|
|
707
|
+
capture_output=True, text=True, start_new_session=True, timeout=limit)
|
|
708
|
+
if ids.returncode:
|
|
709
|
+
say(f"could not list {c['cluster']}'s instances: {ids.stderr.strip()[-200:]}")
|
|
710
|
+
return False
|
|
711
|
+
found = ids.stdout.split()
|
|
712
|
+
if not found:
|
|
713
|
+
return True
|
|
714
|
+
done = subprocess.run([*aws, "ec2", "terminate-instances", "--instance-ids", *found], capture_output=True,
|
|
715
|
+
text=True, start_new_session=True, timeout=limit)
|
|
716
|
+
except (subprocess.TimeoutExpired, OSError) as e:
|
|
717
|
+
say(f"could not reach EC2 to terminate {c['cluster']}'s instances ({e})")
|
|
718
|
+
return False
|
|
719
|
+
if done.returncode:
|
|
720
|
+
say(f"could not terminate {', '.join(found)}: {done.stderr.strip()[-200:]}")
|
|
721
|
+
return False
|
|
722
|
+
say(f"terminated {', '.join(found)}")
|
|
723
|
+
return True
|
|
724
|
+
|
|
725
|
+
# --- a RunPod pod ---------------------------------------------------------------------------------------------
|
|
726
|
+
|
|
727
|
+
def runpod_api(self, c: dict):
|
|
728
|
+
from .runpod import RunPod
|
|
729
|
+
return RunPod(self.runpod_key or "", c["api"], timeout=c.get("api_s", 60))
|
|
730
|
+
|
|
731
|
+
def runpod_launch(self, c: dict):
|
|
732
|
+
"""Create the pod (any GPU of the list, in any of the US, Canadian and European datacenters: whichever RunPod
|
|
733
|
+
has free), wait for its SSH port, probe it like a host and run guard.sh there before anything is sent. The pod's name carries its
|
|
734
|
+
deadline, so a later launch can delete it if this driver never does (runpod_sweep)."""
|
|
735
|
+
from .hosts import HostError, probe, ssh_command
|
|
736
|
+
from .runpod import RunPodError
|
|
737
|
+
if not self.runpod_key:
|
|
738
|
+
raise Refused("input", "no RunPod API key (RUNPOD_API_KEY in ~/.config/hyperstudy/runpod.env): perception cloud on runpod checks for it")
|
|
739
|
+
api = self.runpod_api(c)
|
|
740
|
+
self.runpod_sweep(api)
|
|
741
|
+
deadline_at = int(time.time() + c["deadline_s"] - (time.monotonic() - self.started))
|
|
742
|
+
self.pod_name = f"{c['name']}-d{deadline_at}"
|
|
743
|
+
say(f"starting a RunPod pod, {self.pod_name} (at most {c['deadline_s'] / 60:.0f} min, at most ${c['hourly_usd']}/h)")
|
|
744
|
+
keys = self.local / "ssh" # a key made for this pod alone: the user's own keys are never offered to it
|
|
745
|
+
keys.mkdir(mode=0o700, exist_ok=True)
|
|
746
|
+
key = keys / "id_ed25519"
|
|
747
|
+
if not key.exists():
|
|
748
|
+
subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "perception", "-f", str(key)],
|
|
749
|
+
check=True, capture_output=True, stdin=subprocess.DEVNULL)
|
|
750
|
+
body = {"name": self.pod_name, "imageName": c["image"], "gpuTypeIds": c["gpus"], "gpuTypePriority": "custom",
|
|
751
|
+
"gpuCount": 1, "cloudType": "SECURE", "dataCenterIds": c["datacenters"], "dataCenterPriority": "availability",
|
|
752
|
+
# its own disk only: no volume, so nothing (the inputs above all) outlives the pod
|
|
753
|
+
"containerDiskInGb": c["disk_gb"], "volumeInGb": 0, "ports": ["22/tcp"],
|
|
754
|
+
"env": {"PUBLIC_KEY": (keys / "id_ed25519.pub").read_text().strip()},
|
|
755
|
+
# no allowedCudaVersions: its values stop at 13.0 and match a host's exactly, so it excludes the hosts that
|
|
756
|
+
# report 13.2 (driver 595; checked 2026-10-25). guard.sh checks the driver instead
|
|
757
|
+
"dockerStartCmd": ["bash", "-c", removal_at_boot(deadline_at - 60, c["start"])]}
|
|
758
|
+
self.pod_unknown = True # until RunPod answers, a pod may exist that only its name can find
|
|
759
|
+
try:
|
|
760
|
+
pod = api.create_pod(body)
|
|
761
|
+
except RunPodError as e:
|
|
762
|
+
# only a 4xx, a create never sent (this laptop offline) and RunPod's answer when none of the GPUs is free (a
|
|
763
|
+
# 500) prove no pod was made; any other failure may come after it was made (a gateway's 502): teardown looks
|
|
764
|
+
# for it by its name
|
|
765
|
+
if e.nothing_done or "no instances currently available" in str(e):
|
|
766
|
+
self.pod_unknown = False
|
|
767
|
+
if e.nothing_done and e.sent: # RunPod refused it: the same would happen again
|
|
768
|
+
raise Refused("internal", f"RunPod refused to create the pod: {e}")
|
|
769
|
+
raise LaunchFailed(f"RunPod did not start a pod: {e}")
|
|
770
|
+
self.pod, self.pod_unknown = pod["id"], False
|
|
771
|
+
self.pod_usd = float(pod.get("costPerHr") or c["hourly_usd"])
|
|
772
|
+
say(f"pod {self.pod}: ${self.pod_usd}/h")
|
|
773
|
+
if self.pod_usd > c["hourly_usd"]: # its price rose past the list's: the run's reservation would not cover it
|
|
774
|
+
raise Refused("internal", f"RunPod's price for pod {self.pod} is ${self.pod_usd}/h, above the ${c['hourly_usd']}/h "
|
|
775
|
+
"reserved for it: perception's RunPod price list needs updating")
|
|
776
|
+
while True:
|
|
777
|
+
try:
|
|
778
|
+
p = api.pod(self.pod)
|
|
779
|
+
except RunPodError as e:
|
|
780
|
+
if e.status:
|
|
781
|
+
raise LaunchFailed(f"RunPod could not say how pod {self.pod} is: {e}")
|
|
782
|
+
p = {} # no answer this time: the launch's time limit still applies
|
|
783
|
+
if p is None:
|
|
784
|
+
raise LaunchFailed(f"pod {self.pod} went away while it was starting")
|
|
785
|
+
if p.get("publicIp") and (p.get("portMappings") or {}).get("22"):
|
|
786
|
+
break
|
|
787
|
+
if time.monotonic() - self.started > c["launch_s"]:
|
|
788
|
+
raise LaunchFailed(f"pod {self.pod} had no SSH address after {c['launch_s'] / 60:.0f} min")
|
|
789
|
+
time.sleep(POLL_S)
|
|
790
|
+
# its host key is new: accepted the first time, in a file of this attempt's own, and held to after that
|
|
791
|
+
# (quoted: ssh reads an unquoted path with a space as two files). None of the user's ssh config applies (-F none:
|
|
792
|
+
# its IdentityFiles would be offered too), and nothing of this machine's is forwarded to it (an agent, ports, X11)
|
|
793
|
+
self.spec["ssh"] = [*ssh_command(), "-F", "none", "-i", str(key), "-p", str(int(p["portMappings"]["22"])), "-o", "IdentitiesOnly=yes",
|
|
794
|
+
"-o", f'UserKnownHostsFile="{keys / "known_hosts"}"', "-o", "StrictHostKeyChecking=accept-new",
|
|
795
|
+
"-o", "ForwardAgent=no", "-o", "ClearAllForwardings=yes", "-o", "ForwardX11=no"]
|
|
796
|
+
dest = f"root@{p['publicIp']}"
|
|
797
|
+
self.dest = dest
|
|
798
|
+
while True: # its sshd starts a moment after the port is mapped
|
|
799
|
+
try:
|
|
800
|
+
if self.sh("true", check=False, timeout=60).returncode == 0:
|
|
801
|
+
break
|
|
802
|
+
except (Unreachable, subprocess.TimeoutExpired):
|
|
803
|
+
pass
|
|
804
|
+
if time.monotonic() - self.started > c["launch_s"]:
|
|
805
|
+
raise LaunchFailed(f"pod {self.pod}'s SSH did not answer within {c['launch_s'] / 60:.0f} min")
|
|
806
|
+
time.sleep(POLL_S)
|
|
807
|
+
try:
|
|
808
|
+
caps, root = probe(dest, ssh=self.spec["ssh"])
|
|
809
|
+
except HostError as e:
|
|
810
|
+
raise LaunchFailed(f"pod {self.pod} started but could not be probed: {e}")
|
|
811
|
+
self.at(dest, root)
|
|
812
|
+
self.spec.update(uv=caps.tools.get("uv"), path=sorted({str(Path(t).parent) for t in caps.tools.values() if t}))
|
|
813
|
+
if "cuda" not in caps.accelerators:
|
|
814
|
+
raise Refused("resources", f"pod {self.pod} has no NVIDIA GPU that nvidia-smi can see")
|
|
815
|
+
self.check_clock(f"pod {self.pod}") # it removes itself at its deadline, a moment by this laptop's clock
|
|
816
|
+
# its removal at the deadline is running (its start command), and it holds no key to the account
|
|
817
|
+
result = self.sh(f"bash -s {int(c['min_driver'])}", check=False, data=Path(c["guard"]).read_bytes())
|
|
818
|
+
sys.stdout.buffer.write(result.stdout + result.stderr)
|
|
819
|
+
if result.returncode == 75: # not this pod (an old driver), or its checks could not be made: another may do
|
|
820
|
+
why = (result.stderr.decode(errors="replace").strip().splitlines() or ["see the log"])[-1]
|
|
821
|
+
raise LaunchFailed(f"pod {self.pod} was not used: {why.removeprefix('guard: ')}")
|
|
822
|
+
if result.returncode:
|
|
823
|
+
raise GuardRefused(f"guard.sh refused pod {self.pod} (exit {result.returncode}): nothing was sent; see the log")
|
|
824
|
+
self.deadline_set = True
|
|
825
|
+
|
|
826
|
+
def runpod_sweep(self, api):
|
|
827
|
+
"""Delete any perception pod past its deadline (by its name): one a driver never came back to (a laptop that died)
|
|
828
|
+
whose own removal at the deadline (guard.sh) did not happen either. A pod still within its deadline may be another
|
|
829
|
+
run's, and is left alone."""
|
|
830
|
+
from .runpod import POD_NAME, RunPodError
|
|
831
|
+
try:
|
|
832
|
+
pods = api.pods()
|
|
833
|
+
except RunPodError as e:
|
|
834
|
+
say(f"WARNING: could not list RunPod pods to delete any left past their deadline ({e})")
|
|
835
|
+
return
|
|
836
|
+
for p in pods:
|
|
837
|
+
m = POD_NAME.match(p.get("name") or "")
|
|
838
|
+
if m and int(m.group(1)) + SWEEP_GRACE_S < time.time():
|
|
839
|
+
say(f"deleting {p['name']}: a perception pod past its deadline that was never deleted")
|
|
840
|
+
try:
|
|
841
|
+
api.delete_pod(p["id"])
|
|
842
|
+
except RunPodError as e:
|
|
843
|
+
say(f"WARNING: could not delete {p['name']} ({e}): delete it at https://console.runpod.io/pods")
|
|
844
|
+
|
|
845
|
+
def runpod_teardown(self, c: dict):
|
|
846
|
+
"""Delete the pod through the API and wait until RunPod no longer has it (404), within down_s; charged at its
|
|
847
|
+
price for the time it existed. A create with no clear answer may have made one a moment later: looked for by its
|
|
848
|
+
name for up to unknown_s, and taken as never made (charged nothing) if it does not appear."""
|
|
849
|
+
from .runpod import RunPodError
|
|
850
|
+
signal.signal(signal.SIGTERM, signal.SIG_IGN) # deleting the pod must not be interrupted
|
|
851
|
+
if self.pod is None and not self.pod_unknown:
|
|
852
|
+
return # none was made (RunPod refused, or no key): nothing to delete or charge
|
|
853
|
+
api = self.runpod_api(c)
|
|
854
|
+
say(f"deleting pod {self.pod or self.pod_name}")
|
|
855
|
+
start = time.monotonic()
|
|
856
|
+
end = start + c["down_s"]
|
|
857
|
+
gone, problem = False, ""
|
|
858
|
+
while not gone and time.monotonic() < end:
|
|
859
|
+
try: # deleting is idempotent: asked again until RunPod no longer has the pod or the time is up
|
|
860
|
+
if self.pod is None:
|
|
861
|
+
found = [p["id"] for p in api.pods() if p.get("name") == self.pod_name]
|
|
862
|
+
if not found:
|
|
863
|
+
if time.monotonic() - start < min(c.get("unknown_s", 180), c["down_s"]):
|
|
864
|
+
time.sleep(2)
|
|
865
|
+
continue
|
|
866
|
+
say(f"no pod named {self.pod_name} appeared in {c.get('unknown_s', 180):.0f} s: none was made. If one "
|
|
867
|
+
"appears at https://console.runpod.io/pods, delete it (the next RunPod run does, after its deadline)")
|
|
868
|
+
self.pod_unknown = False
|
|
869
|
+
return
|
|
870
|
+
self.pod = found[0] # names are the attempt's own: one at most
|
|
871
|
+
api.delete_pod(self.pod)
|
|
872
|
+
while time.monotonic() < end and api.pod(self.pod) is not None:
|
|
873
|
+
time.sleep(2)
|
|
874
|
+
gone = api.pod(self.pod) is None
|
|
875
|
+
except RunPodError as e:
|
|
876
|
+
problem = str(e)
|
|
877
|
+
say(f"RunPod: {e}")
|
|
878
|
+
time.sleep(min(5.0, max(0.0, end - time.monotonic())))
|
|
879
|
+
price = self.pod_usd or c["hourly_usd"]
|
|
880
|
+
seconds = time.monotonic() - self.started
|
|
881
|
+
if gone: # its time; never more than its deadline once its own removal was set (it cannot outlive it then)
|
|
882
|
+
self.extra_usd = round(price * (min(seconds, c["deadline_s"]) if self.deadline_set else seconds) / 3600, 4)
|
|
883
|
+
elif self.deadline_set: # it removes itself at its deadline at most (guard.sh), and is charged that
|
|
884
|
+
self.extra_usd = round(price * c["deadline_s"] / 3600, 4)
|
|
885
|
+
say(f"WARNING: could not delete pod {self.pod} ({problem or 'still there'}): it removes itself at its deadline; "
|
|
886
|
+
"or delete it at https://console.runpod.io/pods")
|
|
887
|
+
else: # nothing ends it but someone: no other attempt starts another one
|
|
888
|
+
self.extra_usd = round(price * c["deadline_s"] / 3600, 4)
|
|
889
|
+
message = (f"pod {self.pod or self.pod_name} could not be deleted ({problem or 'still there'}), and its removal at "
|
|
890
|
+
"its deadline was not confirmed: it may still be running. Delete it at https://console.runpod.io/pods")
|
|
891
|
+
say(f"WARNING: {message}")
|
|
892
|
+
self.fail("internal", message)
|
|
893
|
+
say(f"the pod cost about ${self.extra_usd:.2f}" + ("" if gone else " (at least)"))
|
|
894
|
+
|
|
895
|
+
def run(self) -> int:
|
|
896
|
+
cloud = self.spec.get("cloud")
|
|
897
|
+
try:
|
|
898
|
+
if cloud:
|
|
899
|
+
self.provision(cloud)
|
|
900
|
+
# each repeated while the host cannot be reached: they can be (uploads go under the attempt's own names and
|
|
901
|
+
# are published in one step; starting stops this attempt's node if a lost answer hid that it had started)
|
|
902
|
+
with self.phase("code"):
|
|
903
|
+
self.patiently(self.check_root)
|
|
904
|
+
self.patiently(self.send_code)
|
|
905
|
+
with self.phase("inputs"):
|
|
906
|
+
inputs = self.patiently(self.send_inputs)
|
|
907
|
+
with self.phase("prepare"): # its environment synced and its weights fetched, before it starts (setup.py)
|
|
908
|
+
self.prepare()
|
|
909
|
+
with self.phase("run"): # from starting the node to its end
|
|
910
|
+
self.patiently(self.start, inputs)
|
|
911
|
+
self.watch()
|
|
912
|
+
with self.phase("fetch"):
|
|
913
|
+
self.fetch()
|
|
914
|
+
return 0
|
|
915
|
+
except Canceled:
|
|
916
|
+
self.stop()
|
|
917
|
+
return 143
|
|
918
|
+
except LaunchFailed as e:
|
|
919
|
+
say(str(e))
|
|
920
|
+
# retried within the policy, charged what the machine cost until it was deleted (not the whole deadline)
|
|
921
|
+
self.fail("transient", str(e))
|
|
922
|
+
return 1
|
|
923
|
+
except GuardRefused as e:
|
|
924
|
+
say(str(e))
|
|
925
|
+
# the machine is not set up as this repository requires (its role, credentials on it): not worth retrying
|
|
926
|
+
self.fail("privacy", str(e))
|
|
927
|
+
return 1
|
|
928
|
+
except Deadline:
|
|
929
|
+
minutes = cloud["deadline_s"] / 60
|
|
930
|
+
say(f"stopping it: the machine's deadline ({minutes:.0f} min) is near")
|
|
931
|
+
self.stop()
|
|
932
|
+
# a failure of its own, not a crash: another attempt would only reach the same deadline, at the same cost
|
|
933
|
+
if not (self.local / "response.json.part").exists(): # never over results already brought back
|
|
934
|
+
self.fail("resources", f"stopped at the cloud machine's deadline ({minutes:.0f} min); a longer video needs a longer deadline")
|
|
935
|
+
return 124
|
|
936
|
+
except Unreachable as e:
|
|
937
|
+
say(f"{self.spec['host']} could not be reached for {self.spec['lost_after_s']:.0f} s ({e}): giving up on this attempt")
|
|
938
|
+
return 75
|
|
939
|
+
except Refused as e:
|
|
940
|
+
say(str(e))
|
|
941
|
+
self.fail(e.code, str(e))
|
|
942
|
+
return 1
|
|
943
|
+
except PrepareFailed as e:
|
|
944
|
+
say(str(e))
|
|
945
|
+
self.fail("internal", str(e)) # not retried (see PrepareFailed); charged what it took
|
|
946
|
+
return 1
|
|
947
|
+
finally:
|
|
948
|
+
signal.signal(signal.SIGTERM, signal.SIG_IGN) # a cancel now must not skip deleting the machine or charging it
|
|
949
|
+
self.discard_incoming()
|
|
950
|
+
if cloud:
|
|
951
|
+
with self.phase("teardown"):
|
|
952
|
+
if cloud.get("provider") == "runpod":
|
|
953
|
+
self.runpod_teardown(cloud)
|
|
954
|
+
else:
|
|
955
|
+
self.teardown(cloud)
|
|
956
|
+
self.publish_response()
|
|
957
|
+
|
|
958
|
+
def discard_incoming(self):
|
|
959
|
+
"""This attempt's own uploads that never finished (a cancel, a failure): a partial copy of an input must not stay
|
|
960
|
+
on the host. One that finished was renamed into place, so nothing published is touched."""
|
|
961
|
+
if not self.uploading:
|
|
962
|
+
return # nothing was sent (a machine the guard refused gets nothing, not even this)
|
|
963
|
+
q = shlex.quote
|
|
964
|
+
try:
|
|
965
|
+
self.sh(f"chmod -R u+w {q(self.root)}/objects/.incoming-{q(self.tag)}-* 2>/dev/null; "
|
|
966
|
+
f"rm -rf {q(self.root)}/objects/.incoming-{q(self.tag)}-* {q(self.root)}/code/.incoming-{q(self.tag)}", check=False)
|
|
967
|
+
except (Unreachable, subprocess.TimeoutExpired) as e:
|
|
968
|
+
say(f"could not remove this attempt's unfinished uploads on {self.spec['host']} ({e}): perception hosts clean removes them")
|
|
969
|
+
|
|
970
|
+
def fail(self, code: str, message: str):
|
|
971
|
+
"""A response saying the attempt failed, as a node writes one: the coordinator does not retry it (published as
|
|
972
|
+
the run ends, with what a cloud machine cost)."""
|
|
973
|
+
(self.local / "response.json.part").write_text(json.dumps({"ok": False, "usage": {}, "error": {"code": code, "message": message}}))
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def main(argv: list[str] | None = None) -> int:
|
|
977
|
+
ap = argparse.ArgumentParser(prog="perception.remote_driver")
|
|
978
|
+
ap.add_argument("--request", required=True)
|
|
979
|
+
args = ap.parse_args(argv)
|
|
980
|
+
request = Path(args.request)
|
|
981
|
+
|
|
982
|
+
def on_term(signum, frame):
|
|
983
|
+
raise Canceled()
|
|
984
|
+
signal.signal(signal.SIGTERM, on_term)
|
|
985
|
+
driver = Driver(request, json.loads((request.parent / "remote.json").read_text()))
|
|
986
|
+
return driver.run()
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
if __name__ == "__main__":
|
|
990
|
+
sys.exit(main())
|