gpushift 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.
- gpushift/__init__.py +7 -0
- gpushift/__main__.py +4 -0
- gpushift/_entry.py +14 -0
- gpushift/_proc.py +152 -0
- gpushift/autostart.py +97 -0
- gpushift/checkpoint.py +140 -0
- gpushift/cli.py +1100 -0
- gpushift/config.py +269 -0
- gpushift/control.py +658 -0
- gpushift/cpustat.py +171 -0
- gpushift/daemon.py +415 -0
- gpushift/db.py +214 -0
- gpushift/gpumem.py +103 -0
- gpushift/gpus.py +123 -0
- gpushift/hook.py +317 -0
- gpushift/notify.py +70 -0
- gpushift/paths.py +76 -0
- gpushift/runner.py +178 -0
- gpushift/scheduler.py +434 -0
- gpushift/ui.html +574 -0
- gpushift/ui.py +170 -0
- gpushift-0.1.0.dist-info/METADATA +354 -0
- gpushift-0.1.0.dist-info/RECORD +26 -0
- gpushift-0.1.0.dist-info/WHEEL +4 -0
- gpushift-0.1.0.dist-info/entry_points.txt +2 -0
- gpushift-0.1.0.dist-info/licenses/LICENSE +21 -0
gpushift/cli.py
ADDED
|
@@ -0,0 +1,1100 @@
|
|
|
1
|
+
"""gpushift command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shlex
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import __version__, autostart, config, control, daemon, db, gpus, paths
|
|
17
|
+
from ._proc import NO_WINDOW
|
|
18
|
+
from .scheduler import PRESETS, PresetError, preset_requirements
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CLIError(Exception):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------- formatting
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_duration(s: str) -> int:
|
|
29
|
+
"""'40m', '1h30m', '90s', '2h', '1.5h'; a bare number means minutes."""
|
|
30
|
+
s = s.strip().lower()
|
|
31
|
+
if re.fullmatch(r"\d+(\.\d+)?", s):
|
|
32
|
+
return int(float(s) * 60)
|
|
33
|
+
total = 0.0
|
|
34
|
+
pos = 0
|
|
35
|
+
for m in re.finditer(r"(\d+(?:\.\d+)?)\s*([dhms])", s):
|
|
36
|
+
if m.start() != pos and s[pos : m.start()].strip():
|
|
37
|
+
raise CLIError(f"cannot parse duration {s!r} (examples: 40m, 1h30m, 90s)")
|
|
38
|
+
total += float(m.group(1)) * {"d": 86400, "h": 3600, "m": 60, "s": 1}[m.group(2)]
|
|
39
|
+
pos = m.end()
|
|
40
|
+
if pos == 0 or s[pos:].strip():
|
|
41
|
+
raise CLIError(f"cannot parse duration {s!r} (examples: 40m, 1h30m, 90s)")
|
|
42
|
+
return int(total)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_size(s: str) -> int:
|
|
46
|
+
"""'6G', '6GB', '500M', '1.5g'; a bare number means GB. Returns MiB."""
|
|
47
|
+
m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([gm]i?b?)?\s*", s.lower())
|
|
48
|
+
if not m:
|
|
49
|
+
raise CLIError(f"cannot parse memory size {s!r} (examples: 6G, 500M)")
|
|
50
|
+
n = float(m.group(1))
|
|
51
|
+
return int(n if (m.group(2) or "g").startswith("m") else n * 1024)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def fmt_dur(sec: float | None) -> str:
|
|
55
|
+
if sec is None:
|
|
56
|
+
return "?"
|
|
57
|
+
sec = int(max(sec, 0))
|
|
58
|
+
if sec < 60:
|
|
59
|
+
return f"{sec}s"
|
|
60
|
+
m, _ = divmod(sec, 60)
|
|
61
|
+
h, m = divmod(m, 60)
|
|
62
|
+
d, h = divmod(h, 24)
|
|
63
|
+
if d:
|
|
64
|
+
return f"{d}d{h:02d}h"
|
|
65
|
+
if h:
|
|
66
|
+
return f"{h}h{m:02d}m"
|
|
67
|
+
return f"{m}m"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def fmt_clock(ts: float | None) -> str:
|
|
71
|
+
if ts is None:
|
|
72
|
+
return "?"
|
|
73
|
+
lt = time.localtime(ts)
|
|
74
|
+
if time.strftime("%Y%m%d", lt) == time.strftime("%Y%m%d"):
|
|
75
|
+
return time.strftime("%H:%M", lt)
|
|
76
|
+
return time.strftime("%a %H:%M", lt)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def fmt_start(ts: float | None, mode: str) -> str:
|
|
80
|
+
if mode != "normal":
|
|
81
|
+
return "paused"
|
|
82
|
+
if ts is None:
|
|
83
|
+
return "unknown"
|
|
84
|
+
rel = ts - time.time()
|
|
85
|
+
if rel < 30:
|
|
86
|
+
return "now"
|
|
87
|
+
return f"~{fmt_dur(rel)} ({fmt_clock(ts)})"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def iso(ts: float | None) -> str | None:
|
|
91
|
+
return None if ts is None else time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime(ts))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def trunc(s: str, n: int) -> str:
|
|
95
|
+
return s if len(s) <= n else s[: n - 3] + "..."
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def job_public(con, j: dict) -> dict:
|
|
99
|
+
runtime = control.ran(j)
|
|
100
|
+
return {
|
|
101
|
+
"id": j["id"],
|
|
102
|
+
"state": j["state"],
|
|
103
|
+
"preset": j["preset"],
|
|
104
|
+
"n_gpus": j["n_gpus"],
|
|
105
|
+
"slots": j["slots"],
|
|
106
|
+
"gpus": j["gpus"] or [],
|
|
107
|
+
"tag": j["tag"],
|
|
108
|
+
"project": j["project"],
|
|
109
|
+
"session": j["session"],
|
|
110
|
+
"command": j["command"],
|
|
111
|
+
"cwd": j["cwd"],
|
|
112
|
+
"submitted_at": iso(j["submitted_at"]),
|
|
113
|
+
"started_at": iso(j["started_at"]),
|
|
114
|
+
"ended_at": iso(j["ended_at"]),
|
|
115
|
+
"frozen_at": iso(j["frozen_at"]),
|
|
116
|
+
"runtime_s": round(runtime),
|
|
117
|
+
"exit_code": j["exit_code"],
|
|
118
|
+
"eta_s": j["eta_seconds"],
|
|
119
|
+
"expected_duration_s": (lambda d: None if d is None else round(d))(control.expected_duration(con, j)),
|
|
120
|
+
"resumable": control.is_resumable(j),
|
|
121
|
+
"restartable": bool(j["restartable"]),
|
|
122
|
+
"vram_mb": j["vram_mb"],
|
|
123
|
+
"peak_vram_mb": j["peak_vram_mb"],
|
|
124
|
+
"evictions": j["evictions"],
|
|
125
|
+
"resume": bool(j["resume"]),
|
|
126
|
+
"after": j.get("after_ids") or [],
|
|
127
|
+
"log": str(paths.log_file(j["id"])),
|
|
128
|
+
"note": j["note"],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------- context
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def load_cfg() -> config.Config:
|
|
136
|
+
try:
|
|
137
|
+
return config.load()
|
|
138
|
+
except config.ConfigError as e:
|
|
139
|
+
raise CLIError(f"invalid config: {e}") from e
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def project_name(cwd: str) -> str:
|
|
143
|
+
try:
|
|
144
|
+
r = subprocess.run(
|
|
145
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
146
|
+
cwd=cwd,
|
|
147
|
+
capture_output=True,
|
|
148
|
+
text=True,
|
|
149
|
+
timeout=3,
|
|
150
|
+
creationflags=NO_WINDOW,
|
|
151
|
+
)
|
|
152
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
153
|
+
return Path(r.stdout.strip()).name
|
|
154
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
155
|
+
pass
|
|
156
|
+
return Path(cwd).name or cwd
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def git_bash() -> str | None:
|
|
160
|
+
"""On Windows, the bash.exe of the Git Bash (MSYS2) session gpushift was called
|
|
161
|
+
from, or None. Never WSL's bash in System32, which is a different system."""
|
|
162
|
+
if os.name != "nt" or not os.environ.get("MSYSTEM"):
|
|
163
|
+
return None
|
|
164
|
+
exe = shutil.which("bash")
|
|
165
|
+
if not exe or Path(exe).parent.name.lower() in ("system32", "windowsapps"):
|
|
166
|
+
return None
|
|
167
|
+
return exe
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def windows_style(command: str) -> bool:
|
|
171
|
+
"""A shell string written for cmd.exe: it has backslash paths such as
|
|
172
|
+
`.venv\\Scripts\\python.exe`, which bash would read as escapes."""
|
|
173
|
+
return bool(re.search(r"[\w.]\\[\w.]", command))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def check_program_name(cmd: list[str]) -> None:
|
|
177
|
+
"""Catch a command that was half-quoted, e.g. `"python run.py a 1" --flag`:
|
|
178
|
+
the quoted part would be taken as the name of one program."""
|
|
179
|
+
prog = cmd[0]
|
|
180
|
+
if len(cmd) < 2 or not re.search(r"\s", prog) or os.path.exists(prog) or shutil.which(prog):
|
|
181
|
+
return
|
|
182
|
+
raise CLIError(
|
|
183
|
+
f"the program to run is {prog!r}, which contains spaces, so gpushift would look for a program with that whole "
|
|
184
|
+
"name. Pass the command as separate words (gpushift submit -- python run.py a 1 --flag) or quote all of it "
|
|
185
|
+
'as one string (gpushift submit -- "python run.py a 1 --flag").'
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def check_runnable(cmd: list[str], cwd: str) -> None:
|
|
190
|
+
"""Fail at submit time, not hours later, when the program or the Python
|
|
191
|
+
script does not exist."""
|
|
192
|
+
prog = cmd[0]
|
|
193
|
+
if not (os.path.exists(os.path.join(cwd, prog)) or shutil.which(prog)):
|
|
194
|
+
raise CLIError(f"program not found: {prog!r} (not on PATH and not a file in {cwd})")
|
|
195
|
+
if not re.fullmatch(r"(python(\d+(\.\d+)?)?|py|pythonw)(\.exe)?", Path(prog).name, re.I):
|
|
196
|
+
return
|
|
197
|
+
args = iter(cmd[1:])
|
|
198
|
+
for a in args:
|
|
199
|
+
if a in ("-m", "-c", "-"):
|
|
200
|
+
return
|
|
201
|
+
if a in ("-W", "-X"): # options that take a value
|
|
202
|
+
next(args, None)
|
|
203
|
+
continue
|
|
204
|
+
if a.startswith("-"):
|
|
205
|
+
continue
|
|
206
|
+
if a.endswith(".py") and not os.path.exists(os.path.join(cwd, a)):
|
|
207
|
+
raise CLIError(f"script not found: {a!r} (relative to {cwd})")
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def ensure_daemon(quiet: bool = False) -> bool:
|
|
212
|
+
if daemon.is_running():
|
|
213
|
+
return True
|
|
214
|
+
ok = daemon.start_background()
|
|
215
|
+
if not quiet:
|
|
216
|
+
print(
|
|
217
|
+
"started the gpushift daemon"
|
|
218
|
+
if ok
|
|
219
|
+
else "warning: could not start the gpushift daemon; run `gpushift doctor`",
|
|
220
|
+
file=sys.stderr,
|
|
221
|
+
)
|
|
222
|
+
return ok
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def get_job_or_fail(con, job_id: int) -> dict:
|
|
226
|
+
j = db.get_job(con, job_id)
|
|
227
|
+
if j is None:
|
|
228
|
+
raise CLIError(f"job {job_id} not found")
|
|
229
|
+
return j
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _print_json(obj) -> None:
|
|
233
|
+
print(json.dumps(obj, indent=2))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------- commands
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def cmd_submit(a) -> int:
|
|
240
|
+
cmd = list(a.cmd or [])
|
|
241
|
+
if cmd and cmd[0] == "--":
|
|
242
|
+
cmd = cmd[1:]
|
|
243
|
+
if not cmd:
|
|
244
|
+
raise CLIError("nothing to run. Usage: gpushift submit --one -- python train.py")
|
|
245
|
+
cfg = load_cfg()
|
|
246
|
+
preset = a.preset or ("one" if cfg.gpus else "cpu")
|
|
247
|
+
try:
|
|
248
|
+
req = preset_requirements(preset, len(cfg.gpus), cfg.slots, gpus=a.gpus, slots=a.slots)
|
|
249
|
+
except PresetError as e:
|
|
250
|
+
raise CLIError(str(e)) from e
|
|
251
|
+
if preset == "light" and cfg.shared_gpu is None:
|
|
252
|
+
raise CLIError("--light needs a shared GPU, but none is configured")
|
|
253
|
+
|
|
254
|
+
cwd = os.getcwd()
|
|
255
|
+
check_program_name(cmd)
|
|
256
|
+
shell = len(cmd) == 1 and bool(re.search(r"[\s&|<>;]", cmd[0]))
|
|
257
|
+
if not shell:
|
|
258
|
+
check_runnable(cmd, cwd)
|
|
259
|
+
command = cmd[0] if len(cmd) == 1 else (subprocess.list2cmdline(cmd) if os.name == "nt" else shlex.join(cmd))
|
|
260
|
+
if shell and not windows_style(cmd[0]) and (bash := git_bash()):
|
|
261
|
+
# Written for the Git Bash it was submitted from (/c/... paths, cp,
|
|
262
|
+
# 2>&1); cmd.exe would misread it, so run it in that bash.
|
|
263
|
+
cmd, shell = [bash, "-c", cmd[0]], False
|
|
264
|
+
project = project_name(cwd)
|
|
265
|
+
con = db.connect()
|
|
266
|
+
after = parse_after(con, a.after)
|
|
267
|
+
with db.tx(con):
|
|
268
|
+
pos = db.front_position(con) if a.priority else db.back_position(con)
|
|
269
|
+
jid = db.insert_job(
|
|
270
|
+
con,
|
|
271
|
+
state="queued",
|
|
272
|
+
prio=int(a.priority),
|
|
273
|
+
preset=preset,
|
|
274
|
+
n_gpus=req["n_gpus"],
|
|
275
|
+
slots=req["slots"],
|
|
276
|
+
exclusive=int(req["exclusive"]),
|
|
277
|
+
shared=int(req["shared"]),
|
|
278
|
+
position=pos,
|
|
279
|
+
argv=cmd,
|
|
280
|
+
command=command,
|
|
281
|
+
shell=int(shell),
|
|
282
|
+
cwd=cwd,
|
|
283
|
+
env=control.filtered_env(),
|
|
284
|
+
tag=a.tag or project,
|
|
285
|
+
project=project,
|
|
286
|
+
session=os.environ.get("CLAUDE_CODE_SESSION_ID"),
|
|
287
|
+
eta_seconds=parse_duration(a.eta) if a.eta else None,
|
|
288
|
+
resumable=int(a.resumable),
|
|
289
|
+
restartable=int(a.restartable),
|
|
290
|
+
vram_mb=parse_size(a.vram) if a.vram else None,
|
|
291
|
+
after_ids=after or None,
|
|
292
|
+
)
|
|
293
|
+
paths.job_dir(jid).mkdir(parents=True, exist_ok=True)
|
|
294
|
+
if not a.no_start:
|
|
295
|
+
ensure_daemon(quiet=a.json)
|
|
296
|
+
|
|
297
|
+
queue, starts = control.queue_and_starts(con, cfg)
|
|
298
|
+
position = next((i + 1 for i, q in enumerate(queue) if q["id"] == jid), None)
|
|
299
|
+
est = starts.get(jid)
|
|
300
|
+
mode = db.get_mode(con)
|
|
301
|
+
if a.json:
|
|
302
|
+
_print_json(
|
|
303
|
+
{
|
|
304
|
+
"id": jid,
|
|
305
|
+
"preset": preset,
|
|
306
|
+
"position": position,
|
|
307
|
+
"est_start": iso(est),
|
|
308
|
+
"mode": mode,
|
|
309
|
+
"log": str(paths.log_file(jid)),
|
|
310
|
+
}
|
|
311
|
+
)
|
|
312
|
+
return 0
|
|
313
|
+
desc = {
|
|
314
|
+
"light": "shared GPU",
|
|
315
|
+
"one": "1 GPU",
|
|
316
|
+
"two": f"{req['n_gpus']} GPUs",
|
|
317
|
+
"exclusive": "whole machine",
|
|
318
|
+
"cpu": "CPU only",
|
|
319
|
+
}[preset]
|
|
320
|
+
print(
|
|
321
|
+
f"job {jid} queued ({preset}: {desc}, {req['slots']} slot{'s' if req['slots'] != 1 else ''}) tag={a.tag or project}"
|
|
322
|
+
)
|
|
323
|
+
waits = next((q["waiting_for"] for q in queue if q["id"] == jid), [])
|
|
324
|
+
if waits:
|
|
325
|
+
print(f"position {position} of {len(queue)}; starts after job {', '.join(map(str, waits))} has finished")
|
|
326
|
+
else:
|
|
327
|
+
print(f"position {position} of {len(queue)}, estimated start: {fmt_start(est, mode)}")
|
|
328
|
+
if mode != "normal":
|
|
329
|
+
print("note: the queue is paused (free mode); it will start after `gpushift resume`")
|
|
330
|
+
print(f"logs: gpushift logs {jid} -f")
|
|
331
|
+
return 0
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def parse_after(con, values: list[str] | None) -> list[int]:
|
|
335
|
+
"""--after 12 --after 13,14 -> [12, 13, 14], checking that each job exists
|
|
336
|
+
and can still finish successfully."""
|
|
337
|
+
ids: list[int] = []
|
|
338
|
+
for v in values or []:
|
|
339
|
+
for part in v.replace(" ", "").split(","):
|
|
340
|
+
if not part:
|
|
341
|
+
continue
|
|
342
|
+
if not part.isdigit():
|
|
343
|
+
raise CLIError(f"--after takes job ids, e.g. --after 12 or --after 12,13 (got {part!r})")
|
|
344
|
+
ids.append(int(part))
|
|
345
|
+
for i in dict.fromkeys(ids):
|
|
346
|
+
j = db.get_job(con, i)
|
|
347
|
+
if j is None:
|
|
348
|
+
raise CLIError(f"--after {i}: there is no job {i}")
|
|
349
|
+
if j["state"] in ("failed", "cancelled"):
|
|
350
|
+
raise CLIError(f"--after {i}: job {i} {j['state']}, so this job would never run")
|
|
351
|
+
return list(dict.fromkeys(ids))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def timeline(con, running: list[dict], queue: list[dict], placed: dict) -> dict:
|
|
355
|
+
"""What runs on which GPU and when: running jobs (end = expected end or
|
|
356
|
+
None) and the queued jobs the forecast can place. Times are unix seconds."""
|
|
357
|
+
now = time.time()
|
|
358
|
+
items = []
|
|
359
|
+
for j in running:
|
|
360
|
+
left = control.time_left(con, j, now)
|
|
361
|
+
items.append(
|
|
362
|
+
{"id": j["id"], "gpus": j["gpus"] or [], "start": j["started_at"], "end": None if left is None else now + left,
|
|
363
|
+
"state": j["state"]}
|
|
364
|
+
) # fmt: skip
|
|
365
|
+
for j in queue:
|
|
366
|
+
if j["id"] in placed:
|
|
367
|
+
start, gpus, end = placed[j["id"]]
|
|
368
|
+
items.append({"id": j["id"], "gpus": gpus, "start": start, "end": end, "state": "queued"})
|
|
369
|
+
unplaced = [j["id"] for j in queue if j["id"] not in placed]
|
|
370
|
+
return {"now": now, "items": items, "unplaced": unplaced}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def status_data(con, cfg) -> dict:
|
|
374
|
+
mode = db.get_mode(con)
|
|
375
|
+
running = db.active(con)
|
|
376
|
+
placed: dict = {}
|
|
377
|
+
queue, est = control.queue_and_starts(con, cfg, placed)
|
|
378
|
+
live = {g.index: g for g in control.gpu_readings(con)}
|
|
379
|
+
gpu_rows = []
|
|
380
|
+
for i in cfg.gpus:
|
|
381
|
+
g = live.get(i) or next((d for d in cfg.detected if d.index == i), None)
|
|
382
|
+
gpu_rows.append(
|
|
383
|
+
{
|
|
384
|
+
"index": i,
|
|
385
|
+
"name": g.name if g else None,
|
|
386
|
+
"mem_total_mb": g.mem_total if g else None,
|
|
387
|
+
"mem_used_mb": g.mem_used if g else None,
|
|
388
|
+
"util_pct": g.util if g else None,
|
|
389
|
+
"temp_c": g.temp if g else None,
|
|
390
|
+
"power_w": g.power if g else None,
|
|
391
|
+
"power_limit_w": g.power_limit if g else None,
|
|
392
|
+
"fan_pct": g.fan if g else None,
|
|
393
|
+
"clock_mhz": g.clock if g else None,
|
|
394
|
+
"shared": i == cfg.shared_gpu,
|
|
395
|
+
"jobs": [j["id"] for j in running if i in (j["gpus"] or [])],
|
|
396
|
+
}
|
|
397
|
+
)
|
|
398
|
+
hb = db.get_kv(con, "daemon_heartbeat")
|
|
399
|
+
q = []
|
|
400
|
+
for n, j in enumerate(queue, 1):
|
|
401
|
+
d = job_public(con, j)
|
|
402
|
+
d["position"] = n
|
|
403
|
+
d["turn"] = j.get("turn", n) # place in queue order (est_start order can differ)
|
|
404
|
+
d["waiting_for"] = j.get("waiting_for", [])
|
|
405
|
+
d["est_start"] = iso(est.get(j["id"]))
|
|
406
|
+
q.append(d)
|
|
407
|
+
return {
|
|
408
|
+
"schema": 1,
|
|
409
|
+
"mode": mode,
|
|
410
|
+
"mode_since": iso(db.get_kv(con, "mode_since")) if mode != "normal" else None,
|
|
411
|
+
"auto_free": db.get_kv(con, "auto_free") if mode != "normal" else None,
|
|
412
|
+
"daemon": {
|
|
413
|
+
"running": daemon.is_running(),
|
|
414
|
+
"pid": db.get_kv(con, "daemon_pid"),
|
|
415
|
+
"heartbeat_age_s": None if hb is None else round(time.time() - hb, 1),
|
|
416
|
+
},
|
|
417
|
+
"slots": {"total": cfg.slots, "used": sum(j["slots"] for j in running)},
|
|
418
|
+
"gpus": gpu_rows,
|
|
419
|
+
"running": [job_public(con, j) for j in running],
|
|
420
|
+
"queue": q,
|
|
421
|
+
"recent": [job_public(con, j) for j in db.recent(con, 5)],
|
|
422
|
+
"cpu": control.cpu_reading(con),
|
|
423
|
+
"timeline": timeline(con, running, queue, placed),
|
|
424
|
+
"_est": est,
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def cmd_status(a) -> int:
|
|
429
|
+
cfg = load_cfg()
|
|
430
|
+
con = db.connect()
|
|
431
|
+
s = status_data(con, cfg)
|
|
432
|
+
est = s.pop("_est")
|
|
433
|
+
if a.json:
|
|
434
|
+
_print_json(s)
|
|
435
|
+
return 0
|
|
436
|
+
mode = s["mode"]
|
|
437
|
+
mode_txt = {
|
|
438
|
+
"normal": "normal",
|
|
439
|
+
"free": "FREE (queue paused, machine freed)",
|
|
440
|
+
"soft": "FREE --soft (queue paused, jobs frozen)",
|
|
441
|
+
}[mode]
|
|
442
|
+
if mode != "normal" and s.get("auto_free"):
|
|
443
|
+
apps = ", ".join(s["auto_free"].get("apps") or [])
|
|
444
|
+
mode_txt = f"FREE (automatic: {apps} is using the GPU; resumes after it closes)"
|
|
445
|
+
dm = "running" if s["daemon"]["running"] else "NOT RUNNING (`gpushift start`)"
|
|
446
|
+
print(f"gpushift mode: {mode_txt} | daemon: {dm} | slots: {s['slots']['used']}/{s['slots']['total']} used")
|
|
447
|
+
print()
|
|
448
|
+
by_id = {j["id"]: j for j in s["running"]}
|
|
449
|
+
|
|
450
|
+
def job_line(j):
|
|
451
|
+
st = "" if j["state"] == "running" else f" [{j['state'].upper()}]"
|
|
452
|
+
exp = j["expected_duration_s"]
|
|
453
|
+
if j["state"] == "running" and exp and j["runtime_s"] > exp:
|
|
454
|
+
st = f" [over its {fmt_dur(exp)} estimate]"
|
|
455
|
+
return f"job {j['id']:<4} {j['preset']:<9} {trunc(j['tag'] or '', 14):<14} {fmt_dur(j['runtime_s']):>6}{st} {trunc(j['command'], 50)}"
|
|
456
|
+
|
|
457
|
+
for g in s["gpus"]:
|
|
458
|
+
mem = f"{g['mem_used_mb'] / 1024:.1f}/{g['mem_total_mb'] / 1024:.1f} GB" if g["mem_total_mb"] else ""
|
|
459
|
+
name = trunc((g["name"] or "").replace("NVIDIA ", "").replace("GeForce ", ""), 16)
|
|
460
|
+
label = f"GPU {g['index']} {name:<16} {mem:<13}" + ("(shared) " if g["shared"] else " ")
|
|
461
|
+
if not g["jobs"]:
|
|
462
|
+
print(f"{label}idle")
|
|
463
|
+
for k, jid in enumerate(g["jobs"]):
|
|
464
|
+
print(f"{label if k == 0 else ' ' * len(label)}{job_line(by_id[jid])}")
|
|
465
|
+
cpu_jobs = [j for j in s["running"] if not j["gpus"]]
|
|
466
|
+
label = f"{'CPU':<47}"
|
|
467
|
+
if cpu_jobs or not s["gpus"]:
|
|
468
|
+
if not cpu_jobs:
|
|
469
|
+
print(f"{label}idle")
|
|
470
|
+
for k, j in enumerate(cpu_jobs):
|
|
471
|
+
print(f"{label if k == 0 else ' ' * len(label)}{job_line(j)}")
|
|
472
|
+
print()
|
|
473
|
+
if s["queue"]:
|
|
474
|
+
print(f"Queue ({len(s['queue'])} waiting)")
|
|
475
|
+
print(f" {'pos':>3} {'id':<4} {'preset':<9} {'tag':<14} {'waiting':>7} {'est. start':<20} command")
|
|
476
|
+
raw = {q["id"]: q for q in db.queued(con)}
|
|
477
|
+
for j in s["queue"]:
|
|
478
|
+
waited = time.time() - raw[j["id"]]["submitted_at"] if j["id"] in raw else None
|
|
479
|
+
flag = " (resume)" if j["resume"] else ""
|
|
480
|
+
start = fmt_start(est.get(j["id"]), mode)
|
|
481
|
+
if j["waiting_for"] and est.get(j["id"]) is None:
|
|
482
|
+
start = "after job " + ",".join(map(str, j["waiting_for"]))
|
|
483
|
+
print(
|
|
484
|
+
f" {j['position']:>3} {j['id']:<4} {j['preset']:<9} {trunc(j['tag'] or '', 14):<14} {fmt_dur(waited):>7} "
|
|
485
|
+
f"{trunc(start, 20):<20} {trunc(j['command'], 40)}{flag}"
|
|
486
|
+
)
|
|
487
|
+
else:
|
|
488
|
+
print("Queue empty")
|
|
489
|
+
if s["recent"] and not a.short:
|
|
490
|
+
print()
|
|
491
|
+
print("Recent")
|
|
492
|
+
raw = {r["id"]: r for r in db.recent(con, 5)}
|
|
493
|
+
for j in s["recent"]:
|
|
494
|
+
ended = raw[j["id"]]["ended_at"] if j["id"] in raw else None
|
|
495
|
+
rc = "" if j["exit_code"] is None else f" (exit {j['exit_code']})"
|
|
496
|
+
print(
|
|
497
|
+
f" job {j['id']:<4} {j['state']:<9}{rc:<9} {trunc(j['tag'] or '', 14):<14} ran {fmt_dur(j['runtime_s']):>6}, "
|
|
498
|
+
f"ended {fmt_dur(time.time() - ended) if ended else '?'} ago {trunc(j['command'], 36)}"
|
|
499
|
+
)
|
|
500
|
+
return 0
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def cmd_show(a) -> int:
|
|
504
|
+
con = db.connect()
|
|
505
|
+
j = job_public(con, get_job_or_fail(con, a.id))
|
|
506
|
+
if a.json:
|
|
507
|
+
_print_json(j)
|
|
508
|
+
else:
|
|
509
|
+
for k, v in j.items():
|
|
510
|
+
print(f"{k:>20}: {v}")
|
|
511
|
+
return 0
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def cmd_priority(a) -> int:
|
|
515
|
+
con = db.connect()
|
|
516
|
+
get_job_or_fail(con, a.id)
|
|
517
|
+
msg = control.to_front(con, a.id)
|
|
518
|
+
if "moved" not in msg:
|
|
519
|
+
raise CLIError(msg)
|
|
520
|
+
print(msg)
|
|
521
|
+
return 0
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def cmd_swap(a) -> int:
|
|
525
|
+
con = db.connect()
|
|
526
|
+
with db.tx(con):
|
|
527
|
+
x, y = get_job_or_fail(con, a.a), get_job_or_fail(con, a.b)
|
|
528
|
+
for j in (x, y):
|
|
529
|
+
if j["state"] != "queued":
|
|
530
|
+
raise CLIError(f"job {j['id']} is {j['state']}, not queued")
|
|
531
|
+
queue = db.queued(con)
|
|
532
|
+
order = [q["id"] for q in queue]
|
|
533
|
+
i, k = order.index(x["id"]), order.index(y["id"])
|
|
534
|
+
order[i], order[k] = order[k], order[i]
|
|
535
|
+
problem = control.order_problem(queue, order)
|
|
536
|
+
if problem:
|
|
537
|
+
raise CLIError(problem)
|
|
538
|
+
db.update(con, x["id"], position=y["position"], prio=y["prio"])
|
|
539
|
+
db.update(con, y["id"], position=x["position"], prio=x["prio"])
|
|
540
|
+
print(f"swapped jobs {a.a} and {a.b}")
|
|
541
|
+
return 0
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def cmd_cancel(a) -> int:
|
|
545
|
+
con = db.connect()
|
|
546
|
+
rc = 0
|
|
547
|
+
for jid in a.ids:
|
|
548
|
+
msg = control.cancel(con, jid)
|
|
549
|
+
print(msg)
|
|
550
|
+
if "not found" in msg:
|
|
551
|
+
rc = 1
|
|
552
|
+
return rc
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def cmd_retry(a) -> int:
|
|
556
|
+
con = db.connect()
|
|
557
|
+
with db.tx(con):
|
|
558
|
+
j = get_job_or_fail(con, a.id)
|
|
559
|
+
keep = (
|
|
560
|
+
"preset",
|
|
561
|
+
"n_gpus",
|
|
562
|
+
"slots",
|
|
563
|
+
"exclusive",
|
|
564
|
+
"shared",
|
|
565
|
+
"argv",
|
|
566
|
+
"command",
|
|
567
|
+
"shell",
|
|
568
|
+
"cwd",
|
|
569
|
+
"env",
|
|
570
|
+
"tag",
|
|
571
|
+
"project",
|
|
572
|
+
"session",
|
|
573
|
+
"eta_seconds",
|
|
574
|
+
"resumable",
|
|
575
|
+
"restartable",
|
|
576
|
+
"vram_mb",
|
|
577
|
+
)
|
|
578
|
+
jid = db.insert_job(
|
|
579
|
+
con,
|
|
580
|
+
state="queued",
|
|
581
|
+
position=db.front_position(con) if a.priority else db.back_position(con),
|
|
582
|
+
prio=int(a.priority),
|
|
583
|
+
resume=int(a.resume),
|
|
584
|
+
**{k: j[k] for k in keep},
|
|
585
|
+
)
|
|
586
|
+
paths.job_dir(jid).mkdir(parents=True, exist_ok=True)
|
|
587
|
+
ensure_daemon()
|
|
588
|
+
print(f"job {jid} queued (retry of job {a.id})")
|
|
589
|
+
return 0
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _final(con, jid) -> dict | None:
|
|
593
|
+
j = db.get_job(con, jid)
|
|
594
|
+
return j if j and j["state"] in db.FINAL else None
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _emit(data: bytes) -> None:
|
|
598
|
+
"""Write job output. Logs written on Windows contain CRLF; normalize so the
|
|
599
|
+
text-mode stdout does not turn them into CR CR LF."""
|
|
600
|
+
if data:
|
|
601
|
+
sys.stdout.write(data.replace(b"\r\n", b"\n").decode(errors="replace"))
|
|
602
|
+
sys.stdout.flush()
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def cmd_logs(a) -> int:
|
|
606
|
+
con = db.connect()
|
|
607
|
+
get_job_or_fail(con, a.id)
|
|
608
|
+
f = paths.log_file(a.id)
|
|
609
|
+
while not f.exists():
|
|
610
|
+
if not a.follow or _final(con, a.id):
|
|
611
|
+
print(f"(no output yet for job {a.id})")
|
|
612
|
+
return 0
|
|
613
|
+
time.sleep(0.5)
|
|
614
|
+
with open(f, "rb") as fh:
|
|
615
|
+
if a.tail:
|
|
616
|
+
_emit(b"".join(fh.read().splitlines(keepends=True)[-a.tail :]))
|
|
617
|
+
else:
|
|
618
|
+
_emit(fh.read())
|
|
619
|
+
if not a.follow:
|
|
620
|
+
return 0
|
|
621
|
+
try:
|
|
622
|
+
while True:
|
|
623
|
+
chunk = fh.read()
|
|
624
|
+
if chunk:
|
|
625
|
+
_emit(chunk)
|
|
626
|
+
elif _final(con, a.id):
|
|
627
|
+
_emit(fh.read())
|
|
628
|
+
return 0
|
|
629
|
+
else:
|
|
630
|
+
time.sleep(0.5)
|
|
631
|
+
except KeyboardInterrupt:
|
|
632
|
+
return 0
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def cmd_wait(a) -> int:
|
|
636
|
+
con = db.connect()
|
|
637
|
+
get_job_or_fail(con, a.id)
|
|
638
|
+
deadline = None if a.timeout is None else time.time() + parse_duration(a.timeout)
|
|
639
|
+
while True:
|
|
640
|
+
j = _final(con, a.id)
|
|
641
|
+
if j:
|
|
642
|
+
ev = j["evictions"] or 0
|
|
643
|
+
extra = (
|
|
644
|
+
f"; it was evicted {ev}x when the user freed the GPUs and resumed from its own checkpoint" if ev else ""
|
|
645
|
+
)
|
|
646
|
+
print(f"job {a.id} {j['state']} (exit code {j['exit_code']}{extra})")
|
|
647
|
+
if j["state"] == "failed" and j["note"]:
|
|
648
|
+
print(j["note"])
|
|
649
|
+
with db.tx(con): # the caller now knows; no need to notify the session again
|
|
650
|
+
db.update(con, a.id, notified=1)
|
|
651
|
+
return 0 if j["state"] == "done" else (j["exit_code"] or 1)
|
|
652
|
+
if deadline and time.time() > deadline:
|
|
653
|
+
print(f"job {a.id} still {db.get_job(con, a.id)['state']} (timed out waiting)")
|
|
654
|
+
return 124
|
|
655
|
+
time.sleep(1)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _vram_summary(cfg) -> str:
|
|
659
|
+
try:
|
|
660
|
+
live = [g for g in gpus.query() if g.index in cfg.gpus]
|
|
661
|
+
except gpus.GPUQueryError:
|
|
662
|
+
return "(VRAM unknown)"
|
|
663
|
+
return ", ".join(f"GPU {g.index} {g.mem_used / 1024:.1f}/{g.mem_total / 1024:.1f} GB" for g in live) or "(no GPUs)"
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def cmd_free(a) -> int:
|
|
667
|
+
cfg = load_cfg()
|
|
668
|
+
con = db.connect()
|
|
669
|
+
soft = a.soft or (cfg.free_mode_default == "soft" and not a.full)
|
|
670
|
+
rep = control.free(con, soft)
|
|
671
|
+
kind = "soft: everything frozen, VRAM stays allocated" if soft else "full"
|
|
672
|
+
if rep["previous_mode"] != "normal":
|
|
673
|
+
print(f"already in free mode ({rep['previous_mode']}); now {rep['mode']}")
|
|
674
|
+
print(f"Free mode ON ({kind}). The queue is paused; nothing new will start.")
|
|
675
|
+
by = {j["id"]: j for j in db.active(con)}
|
|
676
|
+
|
|
677
|
+
def tag(jid):
|
|
678
|
+
j = by.get(jid) or db.get_job(con, jid)
|
|
679
|
+
return f"job {jid} ({j['tag']})"
|
|
680
|
+
|
|
681
|
+
for jid in rep["evicting"]:
|
|
682
|
+
print(f" evicting: {tag(jid)} - asked to checkpoint and exit")
|
|
683
|
+
for jid in rep["restarting"]:
|
|
684
|
+
print(f" restarting: {tag(jid)} - stopped; it runs again from the front of the queue")
|
|
685
|
+
for jid in rep["frozen"]:
|
|
686
|
+
why = "" if soft else " (it can't checkpoint and isn't --restartable)"
|
|
687
|
+
print(f" frozen: {tag(jid)} - paused, keeps its GPU memory{why}")
|
|
688
|
+
for jid in rep["pending"]:
|
|
689
|
+
print(f" frozen: {tag(jid)} - was just starting; it freezes itself")
|
|
690
|
+
if not (rep["evicting"] or rep["restarting"] or rep["frozen"] or rep["pending"]):
|
|
691
|
+
print(" no jobs were running")
|
|
692
|
+
|
|
693
|
+
leaving = rep["evicting"] + rep["restarting"]
|
|
694
|
+
if leaving and not a.no_wait:
|
|
695
|
+
print(
|
|
696
|
+
f"Waiting for jobs to leave the GPUs (up to {cfg.evict_timeout}s; Ctrl-C stops waiting, they still leave)..."
|
|
697
|
+
)
|
|
698
|
+
waiting = set(leaving)
|
|
699
|
+
try:
|
|
700
|
+
while waiting:
|
|
701
|
+
time.sleep(1)
|
|
702
|
+
control.enforce_evict_timeout(con, cfg.evict_timeout)
|
|
703
|
+
for jid in list(waiting):
|
|
704
|
+
j = db.get_job(con, jid)
|
|
705
|
+
if j["state"] == "queued" and jid in rep["restarting"]:
|
|
706
|
+
print(f" job {jid} stopped -> back at the front of the queue; it runs again on resume")
|
|
707
|
+
waiting.discard(jid)
|
|
708
|
+
elif j["state"] == "queued":
|
|
709
|
+
print(f" job {jid} checkpointed and exited -> back at the front of the queue")
|
|
710
|
+
waiting.discard(jid)
|
|
711
|
+
elif j["state"] == "frozen":
|
|
712
|
+
print(f" job {jid} did not exit within {cfg.evict_timeout}s -> frozen instead (VRAM held)")
|
|
713
|
+
waiting.discard(jid)
|
|
714
|
+
elif j["state"] in db.FINAL:
|
|
715
|
+
print(f" job {jid} ended ({j['state']})")
|
|
716
|
+
waiting.discard(jid)
|
|
717
|
+
except KeyboardInterrupt:
|
|
718
|
+
print(" (stopped waiting)")
|
|
719
|
+
print(f"VRAM in use now: {_vram_summary(cfg)}")
|
|
720
|
+
_explain_frozen(con, cfg)
|
|
721
|
+
print("Run `gpushift resume` to continue.")
|
|
722
|
+
return 0
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _explain_frozen(con, cfg) -> None:
|
|
726
|
+
"""Say which GPUs frozen jobs keep memory on, and how to release it."""
|
|
727
|
+
frozen = [j for j in db.active(con) if j["state"] == "frozen" and j["gpus"]]
|
|
728
|
+
if not frozen:
|
|
729
|
+
return
|
|
730
|
+
try:
|
|
731
|
+
live = {g.index: g for g in gpus.query()}
|
|
732
|
+
except gpus.GPUQueryError:
|
|
733
|
+
live = {}
|
|
734
|
+
print("Frozen jobs keep their GPU memory:")
|
|
735
|
+
for g in sorted({g for j in frozen for g in j["gpus"]}):
|
|
736
|
+
mem = f" ({live[g].mem_used / 1024:.1f} GB in use)" if g in live else ""
|
|
737
|
+
names = ", ".join(f"job {j['id']} ({j['tag']})" for j in frozen if g in j["gpus"])
|
|
738
|
+
print(f" GPU {g}{mem}: {names}")
|
|
739
|
+
ids = " ".join(str(j["id"]) for j in frozen)
|
|
740
|
+
print(f"To release it: gpushift cancel {ids} (run it again later with: gpushift retry <id> --priority)")
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def cmd_resume(a) -> int:
|
|
744
|
+
con = db.connect()
|
|
745
|
+
rep = control.resume(con)
|
|
746
|
+
if rep["previous_mode"] == "normal" and not rep["resumed"]:
|
|
747
|
+
print("not in free mode; nothing to resume")
|
|
748
|
+
return 0
|
|
749
|
+
print("Free mode OFF. The queue is running again.")
|
|
750
|
+
for jid in rep["resumed"]:
|
|
751
|
+
print(f" resumed: job {jid}")
|
|
752
|
+
for jid in rep["evicting"]:
|
|
753
|
+
print(f" job {jid} is still checkpointing; it will restart from the front of the queue")
|
|
754
|
+
evicted = [j for j in db.queued(con) if j["resume"]]
|
|
755
|
+
for j in evicted:
|
|
756
|
+
print(f" queued: job {j['id']} will restart from its checkpoint")
|
|
757
|
+
ensure_daemon()
|
|
758
|
+
return 0
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def cmd_start(a) -> int:
|
|
762
|
+
if daemon.is_running():
|
|
763
|
+
print("daemon already running")
|
|
764
|
+
return 0
|
|
765
|
+
if ensure_daemon(quiet=True):
|
|
766
|
+
print("daemon started")
|
|
767
|
+
return 0
|
|
768
|
+
raise CLIError(f"daemon did not start; see {paths.daemon_log()}")
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def cmd_stop(a) -> int:
|
|
772
|
+
if daemon.stop():
|
|
773
|
+
print("daemon stopped (running jobs keep running; queued jobs wait until it is started again)")
|
|
774
|
+
else:
|
|
775
|
+
print("daemon was not running")
|
|
776
|
+
return 0
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def cmd_daemon(a) -> int:
|
|
780
|
+
return daemon.main()
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def cmd_config(a) -> int:
|
|
784
|
+
f = paths.config_file()
|
|
785
|
+
if a.path:
|
|
786
|
+
print(f)
|
|
787
|
+
return 0
|
|
788
|
+
if a.edit:
|
|
789
|
+
if not f.exists():
|
|
790
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
791
|
+
f.write_text(config.template(gpus.detect()))
|
|
792
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ("notepad" if os.name == "nt" else "vi")
|
|
793
|
+
return subprocess.call([*shlex.split(editor, posix=os.name != "nt"), str(f)])
|
|
794
|
+
cfg = load_cfg()
|
|
795
|
+
print(f"# {f}{'' if f.exists() else ' (not created yet; defaults shown)'}")
|
|
796
|
+
if a.json:
|
|
797
|
+
_print_json(cfg.to_dict())
|
|
798
|
+
else:
|
|
799
|
+
for k, v in cfg.to_dict().items():
|
|
800
|
+
print(f"{k} = {v}")
|
|
801
|
+
return 0
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def cmd_init(a) -> int:
|
|
805
|
+
print(f"gpushift {__version__} on {sys.platform}, python {sys.executable}")
|
|
806
|
+
try:
|
|
807
|
+
detected = gpus.query()
|
|
808
|
+
except gpus.GPUQueryError as e:
|
|
809
|
+
print(f"warning: {e}; continuing in CPU-only mode")
|
|
810
|
+
detected = []
|
|
811
|
+
if detected:
|
|
812
|
+
for g in detected:
|
|
813
|
+
print(f" GPU {g.index}: {g.name}, {g.mem_total / 1024:.1f} GB ({g.mem_used / 1024:.1f} GB in use)")
|
|
814
|
+
else:
|
|
815
|
+
print(" no NVIDIA GPUs found -> CPU-only mode (only --cpu/--exclusive jobs)")
|
|
816
|
+
f = paths.config_file()
|
|
817
|
+
if f.exists() and not a.force:
|
|
818
|
+
print(f"config: keeping existing {f}")
|
|
819
|
+
else:
|
|
820
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
821
|
+
f.write_text(config.template(detected))
|
|
822
|
+
print(f"config: wrote {f}")
|
|
823
|
+
cfg = load_cfg()
|
|
824
|
+
for g in cfg.detected:
|
|
825
|
+
if g.free_perc < cfg.gpu_free_perc:
|
|
826
|
+
print(
|
|
827
|
+
f" note: GPU {g.index} is only {g.free_perc:.0f}% free right now (display/other apps); jobs wait until "
|
|
828
|
+
f">= {cfg.gpu_free_perc}% is free. Lower gpu_free_perc in the config if this is normal for it."
|
|
829
|
+
)
|
|
830
|
+
db.connect()
|
|
831
|
+
print(f"state: {paths.state_dir()}")
|
|
832
|
+
if not a.no_autostart:
|
|
833
|
+
try:
|
|
834
|
+
print(f"autostart: {autostart.install()}")
|
|
835
|
+
except Exception as e:
|
|
836
|
+
print(f"autostart: failed ({e}); start the daemon manually with `gpushift start`")
|
|
837
|
+
print("daemon: running" if ensure_daemon(quiet=True) else f"daemon: FAILED to start (see {paths.daemon_log()})")
|
|
838
|
+
print(f"\nReady: {len(cfg.gpus)} GPU(s), {cfg.slots} slots, shared GPU for --light: {cfg.shared_gpu}.")
|
|
839
|
+
print("Try: gpushift submit --cpu -- python -c \"print('hello from gpushift')\" then gpushift status")
|
|
840
|
+
return 0
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def cmd_doctor(a) -> int:
|
|
844
|
+
problems = 0
|
|
845
|
+
|
|
846
|
+
def check(ok: bool | None, label: str, detail: str = "") -> None:
|
|
847
|
+
nonlocal problems
|
|
848
|
+
tag = {True: "ok ", False: "FAIL", None: "warn"}[ok]
|
|
849
|
+
if ok is False:
|
|
850
|
+
problems += 1
|
|
851
|
+
print(f"[{tag}] {label}{(': ' + detail) if detail else ''}")
|
|
852
|
+
|
|
853
|
+
check(True, "gpushift", f"{__version__}, python {sys.version.split()[0]} at {sys.executable}")
|
|
854
|
+
smi = gpus.nvidia_smi_path()
|
|
855
|
+
try:
|
|
856
|
+
found = gpus.query()
|
|
857
|
+
check(
|
|
858
|
+
True if found else None,
|
|
859
|
+
"GPUs",
|
|
860
|
+
", ".join(f"{g.index}:{g.name}" for g in found) or "none found (CPU-only mode)",
|
|
861
|
+
)
|
|
862
|
+
except gpus.GPUQueryError as e:
|
|
863
|
+
check(False, "GPUs", str(e))
|
|
864
|
+
if not smi and not os.environ.get("GPUSHIFT_FAKE_GPUS"):
|
|
865
|
+
check(None, "nvidia-smi", "not found; install the NVIDIA driver to use GPUs")
|
|
866
|
+
f = paths.config_file()
|
|
867
|
+
try:
|
|
868
|
+
cfg = config.load()
|
|
869
|
+
check(
|
|
870
|
+
True,
|
|
871
|
+
"config",
|
|
872
|
+
f"{f if f.exists() else 'defaults (no config file; run gpushift init)'}; gpus={cfg.gpus} slots={cfg.slots} shared_gpu={cfg.shared_gpu}",
|
|
873
|
+
)
|
|
874
|
+
for g in cfg.detected:
|
|
875
|
+
if g.free_perc < cfg.gpu_free_perc:
|
|
876
|
+
check(
|
|
877
|
+
None,
|
|
878
|
+
f"GPU {g.index} memory",
|
|
879
|
+
f"only {g.free_perc:.0f}% free (< gpu_free_perc={cfg.gpu_free_perc}); new jobs will not start on it",
|
|
880
|
+
)
|
|
881
|
+
except config.ConfigError as e:
|
|
882
|
+
check(False, "config", str(e))
|
|
883
|
+
try:
|
|
884
|
+
con = db.connect()
|
|
885
|
+
n = con.execute("SELECT COUNT(*) FROM jobs").fetchone()[0]
|
|
886
|
+
check(True, "database", f"{paths.db_file()} ({n} jobs)")
|
|
887
|
+
hb = db.get_kv(con, "daemon_heartbeat")
|
|
888
|
+
running = daemon.is_running()
|
|
889
|
+
if running:
|
|
890
|
+
age = time.time() - hb if hb else None
|
|
891
|
+
check(
|
|
892
|
+
age is not None and age < 15,
|
|
893
|
+
"daemon",
|
|
894
|
+
f"running, last heartbeat {fmt_dur(age)} ago" if age is not None else "running, no heartbeat yet",
|
|
895
|
+
)
|
|
896
|
+
else:
|
|
897
|
+
check(False, "daemon", "not running; start it with `gpushift start`")
|
|
898
|
+
mode = db.get_mode(con)
|
|
899
|
+
check(
|
|
900
|
+
True if mode == "normal" else None,
|
|
901
|
+
"mode",
|
|
902
|
+
mode + ("" if mode == "normal" else " (queue paused; `gpushift resume`)"),
|
|
903
|
+
)
|
|
904
|
+
except Exception as e:
|
|
905
|
+
check(False, "database", str(e))
|
|
906
|
+
a_st = autostart.status()
|
|
907
|
+
check(True if a_st else None, "autostart", a_st or "not registered (`gpushift init` registers it)")
|
|
908
|
+
print("\nall good" if problems == 0 else f"\n{problems} problem(s)")
|
|
909
|
+
return 0 if problems == 0 else 1
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def cmd_hook(a) -> int:
|
|
913
|
+
from . import hook
|
|
914
|
+
|
|
915
|
+
return hook.main(a.event)
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def cmd_autostart(a) -> int:
|
|
919
|
+
print(autostart.install() if a.action == "on" else autostart.uninstall())
|
|
920
|
+
return 0
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def cmd_ui(a) -> int:
|
|
924
|
+
cfg = load_cfg()
|
|
925
|
+
if not cfg.ui_port:
|
|
926
|
+
raise CLIError("the dashboard is turned off (ui_port = 0 in the config)")
|
|
927
|
+
ensure_daemon()
|
|
928
|
+
url = f"http://127.0.0.1:{cfg.ui_port}/"
|
|
929
|
+
print(f"gpushift dashboard: {url}")
|
|
930
|
+
if not a.no_open:
|
|
931
|
+
import webbrowser
|
|
932
|
+
|
|
933
|
+
webbrowser.open(url)
|
|
934
|
+
return 0
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
# ---------------------------------------------------------------- parser
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
941
|
+
p = argparse.ArgumentParser(
|
|
942
|
+
prog="gpushift", description="Shared GPU job queue. Submit jobs instead of launching them directly."
|
|
943
|
+
)
|
|
944
|
+
p.add_argument("--version", action="version", version=f"gpushift {__version__}")
|
|
945
|
+
sub = p.add_subparsers(dest="command", metavar="<command>")
|
|
946
|
+
|
|
947
|
+
s = sub.add_parser(
|
|
948
|
+
"submit",
|
|
949
|
+
help="queue a job: gpushift submit --one -- python train.py",
|
|
950
|
+
description="Queue a job. Presets: --light (share a GPU), --one (1 GPU), --two (all GPUs), "
|
|
951
|
+
"--exclusive (whole machine), --cpu (no GPU).",
|
|
952
|
+
)
|
|
953
|
+
g = s.add_mutually_exclusive_group()
|
|
954
|
+
for name in PRESETS:
|
|
955
|
+
g.add_argument(f"--{name}", dest="preset", action="store_const", const=name)
|
|
956
|
+
s.add_argument("--gpus", type=int, help="override the number of GPUs")
|
|
957
|
+
s.add_argument("--slots", type=int, help="override the number of slots")
|
|
958
|
+
s.add_argument("--tag", help="label (default: project/repo name)")
|
|
959
|
+
s.add_argument("--eta", help="expected runtime, e.g. 40m, 2h, 1h30m")
|
|
960
|
+
s.add_argument(
|
|
961
|
+
"--resumable",
|
|
962
|
+
action="store_true",
|
|
963
|
+
help="job checkpoints on SIGTERM/stop file (auto-detected with gpushift.checkpoint)",
|
|
964
|
+
)
|
|
965
|
+
s.add_argument(
|
|
966
|
+
"--vram",
|
|
967
|
+
help="GPU memory the job needs per GPU, e.g. 6G or 500M. Jobs with a known need may share a GPU "
|
|
968
|
+
"(gpushift also learns it from earlier runs of the same command)",
|
|
969
|
+
)
|
|
970
|
+
s.add_argument(
|
|
971
|
+
"--restartable",
|
|
972
|
+
action="store_true",
|
|
973
|
+
help="safe to stop and run again (e.g. it skips work it already finished): `gpushift free` stops it "
|
|
974
|
+
"and requeues it at the front instead of freezing it",
|
|
975
|
+
)
|
|
976
|
+
s.add_argument(
|
|
977
|
+
"--priority", action="store_true", help="put the job at the front of the queue (only when the user asks)"
|
|
978
|
+
)
|
|
979
|
+
s.add_argument(
|
|
980
|
+
"--after",
|
|
981
|
+
action="append",
|
|
982
|
+
metavar="JOB",
|
|
983
|
+
help="start only after these jobs finished successfully, e.g. --after 12 or --after 12,13 "
|
|
984
|
+
"(if one fails or is cancelled, this job is cancelled)",
|
|
985
|
+
)
|
|
986
|
+
s.add_argument("--no-start", action="store_true", help="do not auto-start the daemon")
|
|
987
|
+
s.add_argument("--json", action="store_true")
|
|
988
|
+
s.add_argument("cmd", nargs=argparse.REMAINDER, help="command to run (after --)")
|
|
989
|
+
s.set_defaults(func=cmd_submit)
|
|
990
|
+
|
|
991
|
+
s = sub.add_parser("status", aliases=["st", "queue", "q"], help="show GPUs, running jobs and the queue")
|
|
992
|
+
s.add_argument("--json", action="store_true", help="stable machine-readable output (schema 1)")
|
|
993
|
+
s.add_argument("--short", action="store_true", help="omit recent jobs")
|
|
994
|
+
s.set_defaults(func=cmd_status)
|
|
995
|
+
|
|
996
|
+
s = sub.add_parser("show", help="show one job's details")
|
|
997
|
+
s.add_argument("id", type=int)
|
|
998
|
+
s.add_argument("--json", action="store_true")
|
|
999
|
+
s.set_defaults(func=cmd_show)
|
|
1000
|
+
|
|
1001
|
+
s = sub.add_parser("priority", help="move a queued job to the front")
|
|
1002
|
+
s.add_argument("id", type=int)
|
|
1003
|
+
s.set_defaults(func=cmd_priority)
|
|
1004
|
+
|
|
1005
|
+
s = sub.add_parser("swap", help="swap two queued jobs")
|
|
1006
|
+
s.add_argument("a", type=int)
|
|
1007
|
+
s.add_argument("b", type=int)
|
|
1008
|
+
s.set_defaults(func=cmd_swap)
|
|
1009
|
+
|
|
1010
|
+
s = sub.add_parser("cancel", help="cancel queued or running jobs")
|
|
1011
|
+
s.add_argument("ids", type=int, nargs="+")
|
|
1012
|
+
s.set_defaults(func=cmd_cancel)
|
|
1013
|
+
|
|
1014
|
+
s = sub.add_parser("retry", help="queue a copy of a finished/failed job")
|
|
1015
|
+
s.add_argument("id", type=int)
|
|
1016
|
+
s.add_argument("--priority", action="store_true")
|
|
1017
|
+
s.add_argument("--resume", action="store_true", help="set GPUSHIFT_RESUME=1 (continue from checkpoint)")
|
|
1018
|
+
s.set_defaults(func=cmd_retry)
|
|
1019
|
+
|
|
1020
|
+
s = sub.add_parser("logs", help="print a job's output")
|
|
1021
|
+
s.add_argument("id", type=int)
|
|
1022
|
+
s.add_argument("-f", "--follow", action="store_true", help="keep printing until the job ends")
|
|
1023
|
+
s.add_argument("-n", "--tail", type=int, help="only the last N lines")
|
|
1024
|
+
s.set_defaults(func=cmd_logs)
|
|
1025
|
+
|
|
1026
|
+
s = sub.add_parser("wait", help="block until a job ends; exit code mirrors the job")
|
|
1027
|
+
s.add_argument("id", type=int)
|
|
1028
|
+
s.add_argument("--timeout", help="give up after this long (e.g. 30m)")
|
|
1029
|
+
s.set_defaults(func=cmd_wait)
|
|
1030
|
+
|
|
1031
|
+
s = sub.add_parser("free", help="pause the queue and free the GPUs (user only)")
|
|
1032
|
+
m = s.add_mutually_exclusive_group()
|
|
1033
|
+
m.add_argument("--soft", action="store_true", help="freeze everything (fast; VRAM stays allocated)")
|
|
1034
|
+
m.add_argument("--full", action="store_true", help="evict resumable jobs, freeze the rest (default)")
|
|
1035
|
+
s.add_argument("--no-wait", action="store_true", help="do not wait for evicted jobs to exit")
|
|
1036
|
+
s.set_defaults(func=cmd_free)
|
|
1037
|
+
|
|
1038
|
+
s = sub.add_parser("resume", help="leave free mode: unfreeze jobs and restart the queue (user only)")
|
|
1039
|
+
s.set_defaults(func=cmd_resume)
|
|
1040
|
+
|
|
1041
|
+
s = sub.add_parser("init", help="detect GPUs, write config, start the daemon, register autostart")
|
|
1042
|
+
s.add_argument("--force", action="store_true", help="overwrite an existing config")
|
|
1043
|
+
s.add_argument("--no-autostart", action="store_true")
|
|
1044
|
+
s.set_defaults(func=cmd_init)
|
|
1045
|
+
|
|
1046
|
+
s = sub.add_parser("doctor", help="check the installation")
|
|
1047
|
+
s.set_defaults(func=cmd_doctor)
|
|
1048
|
+
|
|
1049
|
+
s = sub.add_parser("config", help="show or edit the config")
|
|
1050
|
+
s.add_argument("--edit", action="store_true")
|
|
1051
|
+
s.add_argument("--path", action="store_true")
|
|
1052
|
+
s.add_argument("--json", action="store_true")
|
|
1053
|
+
s.set_defaults(func=cmd_config)
|
|
1054
|
+
|
|
1055
|
+
s = sub.add_parser("start", help="start the daemon")
|
|
1056
|
+
s.set_defaults(func=cmd_start)
|
|
1057
|
+
s = sub.add_parser("stop", help="stop the daemon (jobs keep running)")
|
|
1058
|
+
s.set_defaults(func=cmd_stop)
|
|
1059
|
+
s = sub.add_parser("autostart", help="register/unregister the daemon at login")
|
|
1060
|
+
s.add_argument("action", choices=["on", "off"])
|
|
1061
|
+
s.set_defaults(func=cmd_autostart)
|
|
1062
|
+
s = sub.add_parser("ui", aliases=["dashboard"], help="open the web dashboard in your browser")
|
|
1063
|
+
s.add_argument("--no-open", action="store_true", help="only print the address")
|
|
1064
|
+
s.set_defaults(func=cmd_ui)
|
|
1065
|
+
|
|
1066
|
+
s = sub.add_parser("daemon", help=argparse.SUPPRESS)
|
|
1067
|
+
s.set_defaults(func=cmd_daemon)
|
|
1068
|
+
s = sub.add_parser("hook", help=argparse.SUPPRESS)
|
|
1069
|
+
s.add_argument("event", nargs="?", default="pretooluse")
|
|
1070
|
+
s.set_defaults(func=cmd_hook)
|
|
1071
|
+
return p
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def main(argv: list[str] | None = None) -> None:
|
|
1075
|
+
for stream in (sys.stdout, sys.stderr):
|
|
1076
|
+
try:
|
|
1077
|
+
stream.reconfigure(errors="replace")
|
|
1078
|
+
except (AttributeError, ValueError):
|
|
1079
|
+
pass
|
|
1080
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
1081
|
+
# `gpushift submit [opts] -- cmd...`: split before argparse sees the command.
|
|
1082
|
+
cmd = None
|
|
1083
|
+
if argv and argv[0] == "submit" and "--" in argv:
|
|
1084
|
+
i = argv.index("--")
|
|
1085
|
+
argv, cmd = argv[:i], argv[i + 1 :]
|
|
1086
|
+
p = build_parser()
|
|
1087
|
+
a = p.parse_args(argv)
|
|
1088
|
+
if cmd is not None:
|
|
1089
|
+
a.cmd = cmd
|
|
1090
|
+
if not getattr(a, "func", None):
|
|
1091
|
+
p.print_help()
|
|
1092
|
+
sys.exit(0)
|
|
1093
|
+
try:
|
|
1094
|
+
rc = a.func(a)
|
|
1095
|
+
except CLIError as e:
|
|
1096
|
+
print(f"gpushift: error: {e}", file=sys.stderr)
|
|
1097
|
+
rc = 2
|
|
1098
|
+
except KeyboardInterrupt:
|
|
1099
|
+
rc = 130
|
|
1100
|
+
sys.exit(rc)
|