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 ADDED
@@ -0,0 +1,7 @@
1
+ """gpushift: a shared GPU job queue.
2
+
3
+ Keep this module free of imports: ``gpushift.checkpoint`` is imported by user
4
+ training scripts and must stay dependency-free.
5
+ """
6
+
7
+ __version__ = "0.1.0"
gpushift/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from gpushift._entry import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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