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/__init__.py
ADDED
gpushift/__main__.py
ADDED
gpushift/_entry.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Console entry point. `gpushift hook` runs before every agent shell command, so
|
|
2
|
+
it skips importing the full CLI (psutil, sqlite, ...)."""
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main() -> None:
|
|
8
|
+
if len(sys.argv) > 1 and sys.argv[1] == "hook":
|
|
9
|
+
from gpushift.hook import main as hook_main
|
|
10
|
+
|
|
11
|
+
sys.exit(hook_main(sys.argv[2] if len(sys.argv) > 2 else "pretooluse"))
|
|
12
|
+
from gpushift.cli import main as cli_main
|
|
13
|
+
|
|
14
|
+
cli_main()
|
gpushift/_proc.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Small cross-platform process helpers (spawning detached, suspending trees)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import psutil
|
|
11
|
+
|
|
12
|
+
IS_WINDOWS = os.name == "nt"
|
|
13
|
+
NO_WINDOW = 0x08000000 if IS_WINDOWS else 0 # CREATE_NO_WINDOW
|
|
14
|
+
NEW_GROUP = 0x00000200 if IS_WINDOWS else 0 # CREATE_NEW_PROCESS_GROUP
|
|
15
|
+
BREAKAWAY = 0x01000000 if IS_WINDOWS else 0 # CREATE_BREAKAWAY_FROM_JOB
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def console_python() -> str:
|
|
19
|
+
"""A python executable that has a (hidden) console: under pythonw, child
|
|
20
|
+
console programs would otherwise pop up their own windows."""
|
|
21
|
+
exe = Path(sys.executable)
|
|
22
|
+
if IS_WINDOWS and exe.name.lower() == "pythonw.exe":
|
|
23
|
+
cand = exe.with_name("python.exe")
|
|
24
|
+
if cand.exists():
|
|
25
|
+
return str(cand)
|
|
26
|
+
return str(exe)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def windowless_python() -> str:
|
|
30
|
+
exe = Path(sys.executable)
|
|
31
|
+
if IS_WINDOWS and exe.name.lower() == "python.exe":
|
|
32
|
+
cand = exe.with_name("pythonw.exe")
|
|
33
|
+
if cand.exists():
|
|
34
|
+
return str(cand)
|
|
35
|
+
return str(exe)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def spawn_detached(argv: list[str], **kw) -> subprocess.Popen:
|
|
39
|
+
"""Start a process that outlives its parent (and the terminal/agent that
|
|
40
|
+
started it). On Windows, try to break away from the caller's job object so
|
|
41
|
+
an agent's shell cleanup cannot kill it."""
|
|
42
|
+
kw.setdefault("stdin", subprocess.DEVNULL)
|
|
43
|
+
kw.setdefault("stdout", subprocess.DEVNULL)
|
|
44
|
+
kw.setdefault("stderr", subprocess.DEVNULL)
|
|
45
|
+
if IS_WINDOWS:
|
|
46
|
+
base = NO_WINDOW | NEW_GROUP
|
|
47
|
+
try:
|
|
48
|
+
return subprocess.Popen(argv, creationflags=base | BREAKAWAY, **kw)
|
|
49
|
+
except OSError:
|
|
50
|
+
return subprocess.Popen(argv, creationflags=base, **kw)
|
|
51
|
+
return subprocess.Popen(argv, start_new_session=True, **kw)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_proc(pid: int | None, ctime: float | None = None) -> psutil.Process | None:
|
|
55
|
+
"""The process with this pid, if it is alive and (when ctime is given) is
|
|
56
|
+
the same process we started (guards against pid reuse)."""
|
|
57
|
+
if not pid:
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
p = psutil.Process(pid)
|
|
61
|
+
if ctime and abs(p.create_time() - ctime) > 1.0:
|
|
62
|
+
return None
|
|
63
|
+
if p.status() == psutil.STATUS_ZOMBIE:
|
|
64
|
+
return None
|
|
65
|
+
return p
|
|
66
|
+
except psutil.Error:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def alive(pid: int | None, ctime: float | None = None) -> bool:
|
|
71
|
+
return get_proc(pid, ctime) is not None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def create_time(pid: int) -> float | None:
|
|
75
|
+
try:
|
|
76
|
+
return psutil.Process(pid).create_time()
|
|
77
|
+
except psutil.Error:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def tree(p: psutil.Process) -> list[psutil.Process]:
|
|
82
|
+
try:
|
|
83
|
+
return [p, *p.children(recursive=True)]
|
|
84
|
+
except psutil.Error:
|
|
85
|
+
return [p]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def suspend_tree(pid: int | None, ctime: float | None = None) -> bool:
|
|
89
|
+
p = get_proc(pid, ctime)
|
|
90
|
+
if p is None:
|
|
91
|
+
return False
|
|
92
|
+
# Parent first so it cannot spawn new children while we work down the tree.
|
|
93
|
+
for q in tree(p):
|
|
94
|
+
try:
|
|
95
|
+
q.suspend()
|
|
96
|
+
except psutil.Error:
|
|
97
|
+
pass
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def resume_tree(pid: int | None, ctime: float | None = None) -> bool:
|
|
102
|
+
p = get_proc(pid, ctime)
|
|
103
|
+
if p is None:
|
|
104
|
+
return False
|
|
105
|
+
for q in reversed(tree(p)):
|
|
106
|
+
try:
|
|
107
|
+
q.resume()
|
|
108
|
+
except psutil.Error:
|
|
109
|
+
pass
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def kill(pid: int | None, ctime: float | None = None, timeout: float = 5.0) -> bool:
|
|
114
|
+
"""Kill this one process, leaving its children running."""
|
|
115
|
+
p = get_proc(pid, ctime)
|
|
116
|
+
if p is None:
|
|
117
|
+
return False
|
|
118
|
+
try:
|
|
119
|
+
p.kill()
|
|
120
|
+
p.wait(timeout)
|
|
121
|
+
except (psutil.Error, psutil.TimeoutExpired):
|
|
122
|
+
pass
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def kill_tree(pid: int | None, ctime: float | None = None, timeout: float = 5.0) -> bool:
|
|
127
|
+
p = get_proc(pid, ctime)
|
|
128
|
+
if p is None:
|
|
129
|
+
return False
|
|
130
|
+
procs = tree(p)
|
|
131
|
+
for q in reversed(procs):
|
|
132
|
+
try:
|
|
133
|
+
q.kill()
|
|
134
|
+
except psutil.Error:
|
|
135
|
+
pass
|
|
136
|
+
psutil.wait_procs(procs, timeout=timeout)
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def terminate(pid: int | None, ctime: float | None = None) -> bool:
|
|
141
|
+
"""Polite stop request: SIGTERM on POSIX. No-op on Windows (gpushift uses a
|
|
142
|
+
stop file there, which the checkpoint helper polls)."""
|
|
143
|
+
if IS_WINDOWS:
|
|
144
|
+
return False
|
|
145
|
+
p = get_proc(pid, ctime)
|
|
146
|
+
if p is None:
|
|
147
|
+
return False
|
|
148
|
+
try:
|
|
149
|
+
p.terminate()
|
|
150
|
+
return True
|
|
151
|
+
except psutil.Error:
|
|
152
|
+
return False
|
gpushift/autostart.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Start the daemon at login: HKCU Run key on Windows, a systemd user service
|
|
2
|
+
on Linux, a LaunchAgent on macOS. None of these need admin rights."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import _proc
|
|
11
|
+
|
|
12
|
+
NAME = "gpushift"
|
|
13
|
+
UNIT = Path.home() / ".config" / "systemd" / "user" / "gpushift.service"
|
|
14
|
+
PLIST = Path.home() / "Library" / "LaunchAgents" / "com.gpushift.daemon.plist"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _cmd() -> list[str]:
|
|
18
|
+
return [_proc.windowless_python(), "-m", "gpushift", "daemon"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def install() -> str:
|
|
22
|
+
if sys.platform == "win32":
|
|
23
|
+
import winreg
|
|
24
|
+
|
|
25
|
+
cmd = subprocess.list2cmdline(_cmd())
|
|
26
|
+
with winreg.OpenKey(
|
|
27
|
+
winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Run", 0, winreg.KEY_SET_VALUE
|
|
28
|
+
) as k:
|
|
29
|
+
winreg.SetValueEx(k, NAME, 0, winreg.REG_SZ, cmd)
|
|
30
|
+
return f"registered at login (HKCU\\...\\Run\\{NAME}): {cmd}"
|
|
31
|
+
if sys.platform == "darwin":
|
|
32
|
+
args = "".join(f"<string>{a}</string>" for a in _cmd())
|
|
33
|
+
PLIST.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
PLIST.write_text(
|
|
35
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
36
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
|
|
37
|
+
f'<plist version="1.0"><dict><key>Label</key><string>com.gpushift.daemon</string>'
|
|
38
|
+
f"<key>ProgramArguments</key><array>{args}</array>"
|
|
39
|
+
"<key>RunAtLoad</key><true/><key>KeepAlive</key><true/></dict></plist>\n"
|
|
40
|
+
)
|
|
41
|
+
subprocess.run(["launchctl", "load", "-w", str(PLIST)], check=False)
|
|
42
|
+
return f"installed LaunchAgent {PLIST}"
|
|
43
|
+
exec_start = " ".join(f'"{a}"' if " " in a else a for a in _cmd())
|
|
44
|
+
UNIT.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
UNIT.write_text(
|
|
46
|
+
"[Unit]\nDescription=gpushift GPU job queue\n\n"
|
|
47
|
+
f"[Service]\nExecStart={exec_start}\nRestart=on-failure\nRestartSec=5\n\n"
|
|
48
|
+
"[Install]\nWantedBy=default.target\n"
|
|
49
|
+
)
|
|
50
|
+
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
|
|
51
|
+
r = subprocess.run(["systemctl", "--user", "enable", "--now", "gpushift.service"], capture_output=True, text=True)
|
|
52
|
+
if r.returncode != 0:
|
|
53
|
+
return f"wrote {UNIT}, but enabling failed: {r.stderr.strip()}"
|
|
54
|
+
return (
|
|
55
|
+
f"installed systemd user service {UNIT}. To keep the queue running after you log out, "
|
|
56
|
+
"run: loginctl enable-linger $USER"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def uninstall() -> str:
|
|
61
|
+
if sys.platform == "win32":
|
|
62
|
+
import winreg
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
with winreg.OpenKey(
|
|
66
|
+
winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Run", 0, winreg.KEY_SET_VALUE
|
|
67
|
+
) as k:
|
|
68
|
+
winreg.DeleteValue(k, NAME)
|
|
69
|
+
except FileNotFoundError:
|
|
70
|
+
return "not registered"
|
|
71
|
+
return "removed from login startup"
|
|
72
|
+
if sys.platform == "darwin":
|
|
73
|
+
if PLIST.exists():
|
|
74
|
+
subprocess.run(["launchctl", "unload", "-w", str(PLIST)], check=False)
|
|
75
|
+
PLIST.unlink()
|
|
76
|
+
return "removed LaunchAgent"
|
|
77
|
+
return "not registered"
|
|
78
|
+
if UNIT.exists():
|
|
79
|
+
subprocess.run(["systemctl", "--user", "disable", "--now", "gpushift.service"], check=False)
|
|
80
|
+
UNIT.unlink()
|
|
81
|
+
return "removed systemd user service"
|
|
82
|
+
return "not registered"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def status() -> str | None:
|
|
86
|
+
"""Description of the registered autostart, or None."""
|
|
87
|
+
if sys.platform == "win32":
|
|
88
|
+
import winreg
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Run") as k:
|
|
92
|
+
return winreg.QueryValueEx(k, NAME)[0]
|
|
93
|
+
except FileNotFoundError:
|
|
94
|
+
return None
|
|
95
|
+
if sys.platform == "darwin":
|
|
96
|
+
return str(PLIST) if PLIST.exists() else None
|
|
97
|
+
return str(UNIT) if UNIT.exists() else None
|
gpushift/checkpoint.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""gpushift.checkpoint: make a training script evictable. Dependency-free.
|
|
2
|
+
|
|
3
|
+
Jobs started by gpushift can always ``import gpushift.checkpoint``, even in an
|
|
4
|
+
environment where gpushift is not installed (gpushift puts this file on PYTHONPATH).
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from gpushift import checkpoint as ckpt
|
|
9
|
+
|
|
10
|
+
state = load(ckpt.latest_checkpoint("ckpts")) # None on a fresh start
|
|
11
|
+
for step in range(start, total):
|
|
12
|
+
train_step()
|
|
13
|
+
if step % 500 == 0 or ckpt.should_stop():
|
|
14
|
+
save(f"ckpts/step{step}.pt")
|
|
15
|
+
if ckpt.should_stop():
|
|
16
|
+
break # exit cleanly; gpushift requeues the job
|
|
17
|
+
|
|
18
|
+
When ``gpushift free`` needs the GPUs back, it asks the job to stop (SIGTERM on
|
|
19
|
+
Linux/macOS, a stop file on every platform). ``should_stop()`` turns True;
|
|
20
|
+
the script saves and exits; gpushift puts the job back at the front of the queue
|
|
21
|
+
with ``GPUSHIFT_RESUME=1`` so it continues from its latest checkpoint.
|
|
22
|
+
|
|
23
|
+
Calling ``should_stop()`` once marks the job as resumable, so gpushift evicts it
|
|
24
|
+
instead of freezing it. Scripts that never call it are frozen (their VRAM stays
|
|
25
|
+
allocated) during ``gpushift free``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import contextlib
|
|
31
|
+
import os
|
|
32
|
+
import signal
|
|
33
|
+
import threading
|
|
34
|
+
import time
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"atomic_path",
|
|
39
|
+
"install",
|
|
40
|
+
"job_id",
|
|
41
|
+
"latest_checkpoint",
|
|
42
|
+
"managed",
|
|
43
|
+
"resuming",
|
|
44
|
+
"should_stop",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
_stop = False
|
|
48
|
+
_installed = False
|
|
49
|
+
_last_poll = 0.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def managed() -> bool:
|
|
53
|
+
"""True when running as a gpushift job."""
|
|
54
|
+
return "GPUSHIFT_JOB_ID" in os.environ
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def job_id() -> int | None:
|
|
58
|
+
v = os.environ.get("GPUSHIFT_JOB_ID")
|
|
59
|
+
return int(v) if v else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _on_signal(signum, frame):
|
|
63
|
+
global _stop
|
|
64
|
+
_stop = True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def install() -> None:
|
|
68
|
+
"""Install the stop handlers and mark this job resumable. Idempotent;
|
|
69
|
+
``should_stop()`` calls it for you."""
|
|
70
|
+
global _installed
|
|
71
|
+
if _installed:
|
|
72
|
+
return
|
|
73
|
+
_installed = True
|
|
74
|
+
if threading.current_thread() is threading.main_thread():
|
|
75
|
+
for name in ("SIGTERM", "SIGBREAK"):
|
|
76
|
+
sig = getattr(signal, name, None)
|
|
77
|
+
if sig is not None:
|
|
78
|
+
with contextlib.suppress(ValueError, OSError):
|
|
79
|
+
signal.signal(sig, _on_signal)
|
|
80
|
+
d = os.environ.get("GPUSHIFT_JOB_DIR")
|
|
81
|
+
if d:
|
|
82
|
+
with contextlib.suppress(OSError):
|
|
83
|
+
Path(d).mkdir(parents=True, exist_ok=True)
|
|
84
|
+
Path(d, "resumable").touch()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def should_stop(poll_interval: float = 1.0) -> bool:
|
|
88
|
+
"""True once gpushift (or anyone sending SIGTERM) wants the job to stop.
|
|
89
|
+
Cheap enough to call every step: the stop file is checked at most once per
|
|
90
|
+
``poll_interval`` seconds."""
|
|
91
|
+
global _stop, _last_poll
|
|
92
|
+
install()
|
|
93
|
+
if _stop:
|
|
94
|
+
return True
|
|
95
|
+
f = os.environ.get("GPUSHIFT_STOP_FILE")
|
|
96
|
+
if f:
|
|
97
|
+
now = time.monotonic()
|
|
98
|
+
if now - _last_poll >= poll_interval:
|
|
99
|
+
_last_poll = now
|
|
100
|
+
if os.path.exists(f):
|
|
101
|
+
_stop = True
|
|
102
|
+
return _stop
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def resuming() -> bool:
|
|
106
|
+
"""True when gpushift restarted this job after an eviction."""
|
|
107
|
+
return os.environ.get("GPUSHIFT_RESUME") == "1"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def latest_checkpoint(directory, pattern: str = "*") -> Path | None:
|
|
111
|
+
"""Newest file or directory in ``directory`` matching ``pattern`` (by
|
|
112
|
+
modification time), or None. Temp files from ``atomic_path`` are ignored."""
|
|
113
|
+
d = Path(directory)
|
|
114
|
+
if not d.is_dir():
|
|
115
|
+
return None
|
|
116
|
+
cands = [p for p in d.glob(pattern) if not p.name.startswith(".") and not p.name.endswith(".gpushift-tmp")]
|
|
117
|
+
if not cands:
|
|
118
|
+
return None
|
|
119
|
+
return max(cands, key=lambda p: p.stat().st_mtime)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@contextlib.contextmanager
|
|
123
|
+
def atomic_path(path):
|
|
124
|
+
"""Write a checkpoint without ever leaving a half-written file behind::
|
|
125
|
+
|
|
126
|
+
with ckpt.atomic_path("ckpts/last.pt") as tmp:
|
|
127
|
+
torch.save(state, tmp)
|
|
128
|
+
"""
|
|
129
|
+
path = Path(path)
|
|
130
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
tmp = path.with_name(path.name + ".gpushift-tmp")
|
|
132
|
+
yield tmp
|
|
133
|
+
os.replace(tmp, path)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _reset_for_tests() -> None:
|
|
137
|
+
global _stop, _installed, _last_poll
|
|
138
|
+
_stop = False
|
|
139
|
+
_installed = False
|
|
140
|
+
_last_poll = 0.0
|