gpumesh 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.
gpumesh/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """gpumesh - distributed compute sharing over a mesh of volunteer machines."""
2
+
3
+ __version__ = "0.1.0"
gpumesh/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running gpumesh as a module: python -m gpumesh"""
2
+
3
+ from .cli import main
4
+
5
+ main()
gpumesh/capability.py ADDED
@@ -0,0 +1,148 @@
1
+ """Hardware probing and benchmarking.
2
+
3
+ Detects the best compute device available and produces a single numeric
4
+ capability score used by the scheduler. torch and psutil are optional:
5
+ without them the probe falls back to CPU info and a pure-Python benchmark.
6
+
7
+ GPU detection uses nvidia-smi (no PyTorch required for detection).
8
+ PyTorch with CUDA is auto-installed when a GPU is detected.
9
+ """
10
+
11
+ import os
12
+ import platform
13
+ import shutil
14
+ import socket
15
+ import subprocess
16
+ import sys
17
+ import time
18
+
19
+
20
+ def _detect_nvidia_gpu() -> dict:
21
+ """Detect NVIDIA GPU using nvidia-smi (no PyTorch required)."""
22
+ if shutil.which("nvidia-smi") is None:
23
+ return None
24
+ try:
25
+ result = subprocess.run(
26
+ ["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"],
27
+ capture_output=True,
28
+ text=True,
29
+ timeout=5,
30
+ )
31
+ if result.returncode == 0 and result.stdout.strip():
32
+ parts = result.stdout.strip().split(",")
33
+ name = parts[0].strip()
34
+ memory = parts[1].strip() if len(parts) > 1 else "unknown"
35
+ return {"name": name, "memory_mb": memory}
36
+ except (subprocess.SubprocessError, FileNotFoundError):
37
+ pass
38
+ return None
39
+
40
+
41
+ def _install_pytorch_cuda():
42
+ """Install PyTorch with CUDA support if NVIDIA GPU detected."""
43
+ print("[mesh] NVIDIA GPU detected, installing PyTorch with CUDA support...")
44
+ try:
45
+ subprocess.run(
46
+ [sys.executable, "-m", "pip", "install", "torch", "torchvision", "torchaudio",
47
+ "--index-url", "https://download.pytorch.org/whl/cu118"],
48
+ check=True,
49
+ timeout=300,
50
+ )
51
+ print("[mesh] PyTorch with CUDA installed successfully!")
52
+ return True
53
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
54
+ print(f"[mesh] Failed to install PyTorch: {e}")
55
+ print("[mesh] Continuing with CPU-only mode.")
56
+ return False
57
+
58
+
59
+ def probe_device() -> dict:
60
+ info = {
61
+ "hostname": socket.gethostname(),
62
+ "platform": platform.platform(),
63
+ "cpu_count": os.cpu_count() or 1,
64
+ "device": "cpu",
65
+ "device_name": platform.processor() or "cpu",
66
+ }
67
+
68
+ # First check for NVIDIA GPU using nvidia-smi (fast, no PyTorch needed)
69
+ nvidia_gpu = _detect_nvidia_gpu()
70
+ if nvidia_gpu:
71
+ info["nvidia_gpu"] = nvidia_gpu["name"]
72
+ info["nvidia_memory"] = nvidia_gpu["memory_mb"]
73
+
74
+ # Try to use PyTorch for GPU detection and benchmarking
75
+ try:
76
+ import torch
77
+ if torch.cuda.is_available():
78
+ info["device"] = "cuda"
79
+ info["device_name"] = torch.cuda.get_device_name(0)
80
+ elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
81
+ info["device"] = "mps"
82
+ info["device_name"] = "Apple Silicon GPU"
83
+ except ImportError:
84
+ # PyTorch not installed - try to auto-install if NVIDIA GPU detected
85
+ if nvidia_gpu:
86
+ if _install_pytorch_cuda():
87
+ try:
88
+ import torch
89
+ if torch.cuda.is_available():
90
+ info["device"] = "cuda"
91
+ info["device_name"] = torch.cuda.get_device_name(0)
92
+ except Exception:
93
+ pass
94
+
95
+ try:
96
+ import psutil
97
+ info["ram_gb"] = round(psutil.virtual_memory().total / 1e9, 1)
98
+ except ImportError:
99
+ pass
100
+
101
+ return info
102
+
103
+
104
+ def _bench_torch(device: str) -> float:
105
+ """Time a matmul on the given torch device; return ops/sec score."""
106
+ import torch
107
+
108
+ n = 1024
109
+ a = torch.rand(n, n, device=device)
110
+ b = torch.rand(n, n, device=device)
111
+ # warmup
112
+ (a @ b).sum().item()
113
+ start = time.perf_counter()
114
+ iters = 10
115
+ for _ in range(iters):
116
+ c = a @ b
117
+ c.sum().item() # force sync on cuda/mps
118
+ elapsed = time.perf_counter() - start
119
+ flops = 2 * n**3 * iters
120
+ return flops / elapsed / 1e9 # GFLOP/s
121
+
122
+
123
+ def _bench_python() -> float:
124
+ """Pure-Python matmul fallback. Slow by design; scores are comparable
125
+ across machines because everyone runs the same loop."""
126
+ n = 48
127
+ a = [[(i * j) % 7 / 7.0 for j in range(n)] for i in range(n)]
128
+ b = [[(i + j) % 5 / 5.0 for j in range(n)] for i in range(n)]
129
+ start = time.perf_counter()
130
+ iters = 5
131
+ for _ in range(iters):
132
+ [[sum(a[i][k] * b[k][j] for k in range(n)) for j in range(n)] for i in range(n)]
133
+ elapsed = time.perf_counter() - start
134
+ flops = 2 * n**3 * iters
135
+ return flops / elapsed / 1e9
136
+
137
+
138
+ def benchmark(device: str) -> float:
139
+ try:
140
+ return round(_bench_torch(device), 3)
141
+ except ImportError:
142
+ return round(_bench_python(), 3)
143
+
144
+
145
+ def full_probe() -> dict:
146
+ info = probe_device()
147
+ info["score"] = benchmark(info["device"])
148
+ return info
gpumesh/cli.py ADDED
@@ -0,0 +1,225 @@
1
+ """gpumesh command line interface.
2
+
3
+ gpumesh serve [--port 8000] [--token SECRET] [--public]
4
+ gpumesh join URL [--token SECRET]
5
+ gpumesh quickjoin URL --token TOKEN # One-click setup & join
6
+ gpumesh submit SCRIPT --payloads FILE [--wait] --url URL [--token SECRET]
7
+ gpumesh status JOB_ID --url URL [--token SECRET]
8
+ gpumesh cancel JOB_ID --url URL [--token SECRET]
9
+ gpumesh workers --url URL [--token SECRET]
10
+ """
11
+
12
+ import argparse
13
+ import os
14
+ import secrets
15
+ import socket
16
+ import subprocess
17
+ import sys
18
+
19
+ from . import client, server, tunnel, worker
20
+
21
+
22
+ def _lan_ip() -> str:
23
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
24
+ try:
25
+ s.connect(("8.8.8.8", 80))
26
+ return s.getsockname()[0]
27
+ except OSError:
28
+ return "127.0.0.1"
29
+ finally:
30
+ s.close()
31
+
32
+
33
+ def cmd_serve(args):
34
+ token = args.token or secrets.token_urlsafe(12)
35
+ httpd = server.serve("0.0.0.0", args.port, args.db, token)
36
+ ip = _lan_ip()
37
+ print(f"[mesh] coordinator listening on 0.0.0.0:{args.port}")
38
+ print(f"[mesh] token: {token}")
39
+ print(f"[mesh] LAN join command:")
40
+ print(f" gpumesh join http://{ip}:{args.port} --token {token}")
41
+ if args.public:
42
+ tunnel.open_tunnel(args.port)
43
+ print("[mesh] Ctrl+C to stop")
44
+ try:
45
+ httpd.serve_forever()
46
+ except KeyboardInterrupt:
47
+ print("\n[mesh] shutting down")
48
+ httpd.gpumesh_stop.set()
49
+ httpd.shutdown()
50
+
51
+
52
+ def cmd_join(args):
53
+ worker.run_worker(args.url, args.token, task_timeout=args.timeout)
54
+
55
+
56
+ def cmd_submit(args):
57
+ job_id = client.submit_job(args.url, args.token, args.script,
58
+ args.payloads, name=args.name)
59
+ print(f"[client] submitted job {job_id}")
60
+ if args.wait:
61
+ job = client.wait_for_job(args.url, args.token, job_id)
62
+ client.print_job(job)
63
+ else:
64
+ print(f"[client] check progress: gpumesh status {job_id}"
65
+ f" --url {args.url} --token {args.token}")
66
+
67
+
68
+ def cmd_status(args):
69
+ job = client.get_status(args.url, args.token, args.job_id)
70
+ client.print_job(job)
71
+
72
+
73
+ def cmd_cancel(args):
74
+ result = client.cancel_job(args.url, args.token, args.job_id)
75
+ if result is None:
76
+ print(f"[client] job {args.job_id} not found")
77
+ else:
78
+ print(f"[client] cancelled job {args.job_id}")
79
+ print(f" pending tasks cancelled: {result['pending']}")
80
+ print(f" running tasks cancelled: {result['running']}")
81
+ print(f" already finished: {result['already_finished']}")
82
+
83
+
84
+ def cmd_workers(args):
85
+ from .worker import MeshClient
86
+
87
+ resp = MeshClient(args.url, args.token).call("GET", "/api/workers")
88
+ for w in resp["workers"]:
89
+ state = "alive" if w["alive"] else "dead"
90
+ print(f" {w['id']} {w['hostname']:<20} {w['device']:<5} "
91
+ f"score={w['score']:<10} [{state}]")
92
+ if not resp["workers"]:
93
+ print(" (no workers yet)")
94
+
95
+
96
+ def cmd_quickjoin(args):
97
+ """One-click setup: install dependencies, detect GPU, and join mesh."""
98
+ url = args.url
99
+ token = args.token
100
+
101
+ print("[mesh] Quick Join - Setting up your machine as a worker...")
102
+ print()
103
+
104
+ # Step 1: Check Python version
105
+ print("[1/4] Checking Python version...")
106
+ if sys.version_info < (3, 9):
107
+ print("[mesh] ERROR: Python 3.9 or higher is required.")
108
+ print("[mesh] Please install Python 3.9+ from https://www.python.org/downloads/")
109
+ return
110
+ print(f"[mesh] Python {sys.version_info.major}.{sys.version_info.minor} ✓")
111
+
112
+ # Step 2: Install gpumesh if not already installed
113
+ print("[2/4] Checking gpumesh installation...")
114
+ try:
115
+ import gpumesh
116
+ print("[mesh] gpumesh already installed ✓")
117
+ except ImportError:
118
+ print("[mesh] Installing gpumesh...")
119
+ try:
120
+ subprocess.run(
121
+ [sys.executable, "-m", "pip", "install", "-e", "."],
122
+ check=True,
123
+ timeout=120,
124
+ )
125
+ print("[mesh] gpumesh installed ✓")
126
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
127
+ print(f"[mesh] ERROR: Failed to install gpumesh: {e}")
128
+ print("[mesh] Please install manually: pip install gpumesh")
129
+ return
130
+
131
+ # Step 3: Detect hardware and install GPU support if needed
132
+ print("[3/4] Detecting hardware...")
133
+ from . import capability
134
+
135
+ # Check for NVIDIA GPU using nvidia-smi
136
+ nvidia_gpu = capability._detect_nvidia_gpu()
137
+ if nvidia_gpu:
138
+ print(f"[mesh] NVIDIA GPU detected: {nvidia_gpu['name']}")
139
+
140
+ # Check if PyTorch with CUDA is available
141
+ try:
142
+ import torch
143
+ if torch.cuda.is_available():
144
+ print(f"[mesh] PyTorch with CUDA ready ✓")
145
+ else:
146
+ print("[mesh] PyTorch installed but CUDA not available")
147
+ capability._install_pytorch_cuda()
148
+ except ImportError:
149
+ print("[mesh] PyTorch not found, installing with CUDA support...")
150
+ capability._install_pytorch_cuda()
151
+ else:
152
+ print("[mesh] No NVIDIA GPU detected, using CPU")
153
+
154
+ # Step 4: Join the mesh
155
+ print("[4/4] Joining the mesh...")
156
+ print(f"[mesh] Connecting to {url}...")
157
+ print()
158
+
159
+ # Now run the normal join command
160
+ worker.run_worker(url, token)
161
+
162
+
163
+ def _add_conn_args(p):
164
+ p.add_argument("--url", default=os.environ.get("GPUMESH_URL", ""),
165
+ help="coordinator URL (or set GPUMESH_URL)")
166
+ p.add_argument("--token", default=os.environ.get("GPUMESH_TOKEN", ""),
167
+ help="shared auth token (or set GPUMESH_TOKEN)")
168
+
169
+
170
+ def main():
171
+ ap = argparse.ArgumentParser(prog="gpumesh",
172
+ description="borrow your friends' GPUs")
173
+ sub = ap.add_subparsers(dest="cmd", required=True)
174
+
175
+ p = sub.add_parser("serve", help="start a coordinator on this machine")
176
+ p.add_argument("--port", type=int, default=8000)
177
+ p.add_argument("--db", default="gpumesh.db")
178
+ p.add_argument("--token", default="", help="auth token (random if omitted)")
179
+ p.add_argument("--public", action="store_true",
180
+ help="expose a public URL via ngrok")
181
+ p.set_defaults(func=cmd_serve)
182
+
183
+ p = sub.add_parser("join", help="offer this machine's compute to a mesh")
184
+ p.add_argument("url", help="coordinator URL")
185
+ p.add_argument("--token", default=os.environ.get("GPUMESH_TOKEN", ""))
186
+ p.add_argument("--timeout", type=float, default=240.0,
187
+ help="per-task wall clock limit in seconds")
188
+ p.set_defaults(func=cmd_join)
189
+
190
+ p = sub.add_parser("submit", help="submit a job to the mesh")
191
+ p.add_argument("script", help="python script to run for every payload")
192
+ p.add_argument("--payloads", required=True,
193
+ help="JSON file: list of payload objects (each may set 'cost')")
194
+ p.add_argument("--name", default="")
195
+ p.add_argument("--wait", action="store_true", help="block until finished")
196
+ _add_conn_args(p)
197
+ p.set_defaults(func=cmd_submit)
198
+
199
+ p = sub.add_parser("status", help="show job progress and results")
200
+ p.add_argument("job_id")
201
+ _add_conn_args(p)
202
+ p.set_defaults(func=cmd_status)
203
+
204
+ p = sub.add_parser("cancel", help="cancel a running job")
205
+ p.add_argument("job_id")
206
+ _add_conn_args(p)
207
+ p.set_defaults(func=cmd_cancel)
208
+
209
+ p = sub.add_parser("quickjoin", help="one-click setup: install, detect GPU, and join mesh")
210
+ p.add_argument("url", help="coordinator URL")
211
+ p.add_argument("--token", required=True, help="shared auth token")
212
+ p.set_defaults(func=cmd_quickjoin)
213
+
214
+ p = sub.add_parser("workers", help="list workers in the mesh")
215
+ _add_conn_args(p)
216
+ p.set_defaults(func=cmd_workers)
217
+
218
+ args = ap.parse_args()
219
+ if hasattr(args, "url") and not args.url and args.cmd != "serve":
220
+ ap.error("--url required (or set GPUMESH_URL)")
221
+ args.func(args)
222
+
223
+
224
+ if __name__ == "__main__":
225
+ main()
gpumesh/client.py ADDED
@@ -0,0 +1,62 @@
1
+ """Client-side helpers: submit jobs, poll status, pretty-print results."""
2
+
3
+ import json
4
+ import sys
5
+ import time
6
+
7
+ from .worker import MeshClient
8
+
9
+
10
+ def cancel_job(url: str, token: str, job_id: str) -> dict:
11
+ """Cancel a running job. Returns a summary of cancelled tasks."""
12
+ return MeshClient(url, token).call("DELETE", f"/api/jobs/{job_id}")
13
+
14
+
15
+ def submit_job(url: str, token: str, script_path: str, payloads_path: str,
16
+ name: str = "") -> str:
17
+ with open(script_path) as f:
18
+ script = f.read()
19
+ with open(payloads_path) as f:
20
+ payloads = json.load(f)
21
+ if not isinstance(payloads, list):
22
+ raise SystemExit("payloads file must contain a JSON list")
23
+
24
+ mesh = MeshClient(url, token)
25
+ resp = mesh.call("POST", "/api/jobs", {
26
+ "name": name or script_path,
27
+ "script": script,
28
+ "payloads": payloads,
29
+ })
30
+ return resp["job_id"]
31
+
32
+
33
+ def get_status(url: str, token: str, job_id: str) -> dict:
34
+ return MeshClient(url, token).call("GET", f"/api/jobs/{job_id}")
35
+
36
+
37
+ def wait_for_job(url: str, token: str, job_id: str, poll: float = 3.0) -> dict:
38
+ while True:
39
+ job = get_status(url, token, job_id)
40
+ counts = job["counts"]
41
+ total = sum(counts.values())
42
+ done = counts.get("done", 0) + counts.get("failed", 0)
43
+ sys.stdout.write(f"\r[job {job_id}] {done}/{total} tasks finished "
44
+ f"({counts}) ")
45
+ sys.stdout.flush()
46
+ if job["finished"]:
47
+ print()
48
+ return job
49
+ time.sleep(poll)
50
+
51
+
52
+ def print_job(job: dict):
53
+ print(f"job: {job['name']} ({job['id']}) finished={job['finished']}")
54
+ for t in job["tasks"]:
55
+ line = f" task {t['id']} [{t['status']}] cost={t['cost']}"
56
+ if t["worker_id"]:
57
+ line += f" worker={t['worker_id']}"
58
+ print(line)
59
+ if t["result"] is not None:
60
+ print(f" result: {json.dumps(t['result'])}")
61
+ if t["error"]:
62
+ print(f" error: {t['error']}")