openspuruntime 0.1.0__tar.gz
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.
- openspuruntime-0.1.0/PKG-INFO +59 -0
- openspuruntime-0.1.0/README.md +45 -0
- openspuruntime-0.1.0/openspuruntime/__init__.py +13 -0
- openspuruntime-0.1.0/openspuruntime/__main__.py +3 -0
- openspuruntime-0.1.0/openspuruntime/cli.py +182 -0
- openspuruntime-0.1.0/openspuruntime/client.py +116 -0
- openspuruntime-0.1.0/openspuruntime/server.py +232 -0
- openspuruntime-0.1.0/openspuruntime/tunnel.py +92 -0
- openspuruntime-0.1.0/openspuruntime.egg-info/PKG-INFO +59 -0
- openspuruntime-0.1.0/openspuruntime.egg-info/SOURCES.txt +14 -0
- openspuruntime-0.1.0/openspuruntime.egg-info/dependency_links.txt +1 -0
- openspuruntime-0.1.0/openspuruntime.egg-info/entry_points.txt +2 -0
- openspuruntime-0.1.0/openspuruntime.egg-info/requires.txt +8 -0
- openspuruntime-0.1.0/openspuruntime.egg-info/top_level.txt +1 -0
- openspuruntime-0.1.0/pyproject.toml +22 -0
- openspuruntime-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openspuruntime
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Remote Alyx I100 SPU runtime: a Flask server tunneled with Cloudflare
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: requests
|
|
9
|
+
Provides-Extra: host
|
|
10
|
+
Requires-Dist: flask; extra == "host"
|
|
11
|
+
Requires-Dist: alyxspu; extra == "host"
|
|
12
|
+
Provides-Extra: waitress
|
|
13
|
+
Requires-Dist: waitress; extra == "waitress"
|
|
14
|
+
|
|
15
|
+
# OpenSPURuntime
|
|
16
|
+
|
|
17
|
+
Use any other computer's Alyx I100 SPU as your own. The host runs a plain Flask
|
|
18
|
+
server in front of the `alyxspu` driver and tunnels it with Cloudflare, so no
|
|
19
|
+
port forwarding or public IP is needed.
|
|
20
|
+
|
|
21
|
+
## Installation :
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
pip install "openspuruntime[host]" # on the machine with the SPU
|
|
25
|
+
pip install openspuruntime # on the client
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Streaming :
|
|
29
|
+
```
|
|
30
|
+
openspuruntime stream --serial-port COM5 --allowed_ip all
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Connecting :
|
|
34
|
+
```
|
|
35
|
+
openspuruntime connect https://random-words.trycloudflare.com/?token=...
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage :
|
|
39
|
+
#### For CLI :
|
|
40
|
+
```
|
|
41
|
+
openspuruntime calc "sqrt(2)*3 + sin(pi/2)"
|
|
42
|
+
openspuruntime build --inputs 2 --hidden 4 --outputs 1
|
|
43
|
+
openspuruntime data -c "[[[0,0],[0]],[[0,1],[1]],[[1,0],[1]],[[1,1],[0]]]"
|
|
44
|
+
openspuruntime train --epochs 3000 --lr 0.5
|
|
45
|
+
openspuruntime run 1 0
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
#### In python :
|
|
49
|
+
```python
|
|
50
|
+
from openspuruntime import Runtime
|
|
51
|
+
rt = Runtime.connect("https://random-words.trycloudflare.com/?token=...")
|
|
52
|
+
print(rt.calc("2+2"))
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Disconnecting
|
|
56
|
+
```
|
|
57
|
+
openspuruntime disconnect
|
|
58
|
+
```
|
|
59
|
+
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# OpenSPURuntime
|
|
2
|
+
|
|
3
|
+
Use any other computer's Alyx I100 SPU as your own. The host runs a plain Flask
|
|
4
|
+
server in front of the `alyxspu` driver and tunnels it with Cloudflare, so no
|
|
5
|
+
port forwarding or public IP is needed.
|
|
6
|
+
|
|
7
|
+
## Installation :
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
pip install "openspuruntime[host]" # on the machine with the SPU
|
|
11
|
+
pip install openspuruntime # on the client
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Streaming :
|
|
15
|
+
```
|
|
16
|
+
openspuruntime stream --serial-port COM5 --allowed_ip all
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Connecting :
|
|
20
|
+
```
|
|
21
|
+
openspuruntime connect https://random-words.trycloudflare.com/?token=...
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage :
|
|
25
|
+
#### For CLI :
|
|
26
|
+
```
|
|
27
|
+
openspuruntime calc "sqrt(2)*3 + sin(pi/2)"
|
|
28
|
+
openspuruntime build --inputs 2 --hidden 4 --outputs 1
|
|
29
|
+
openspuruntime data -c "[[[0,0],[0]],[[0,1],[1]],[[1,0],[1]],[[1,1],[0]]]"
|
|
30
|
+
openspuruntime train --epochs 3000 --lr 0.5
|
|
31
|
+
openspuruntime run 1 0
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
#### In python :
|
|
35
|
+
```python
|
|
36
|
+
from openspuruntime import Runtime
|
|
37
|
+
rt = Runtime.connect("https://random-words.trycloudflare.com/?token=...")
|
|
38
|
+
print(rt.calc("2+2"))
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Disconnecting
|
|
42
|
+
```
|
|
43
|
+
openspuruntime disconnect
|
|
44
|
+
```
|
|
45
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""OpenSPURuntime: remote Alyx I100 SPU runtime."""
|
|
2
|
+
from .client import Runtime, SPUError
|
|
3
|
+
|
|
4
|
+
__version__ = "0.1.0"
|
|
5
|
+
__all__ = ["Runtime", "SPUError", "serve", "create_app"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def __getattr__(name):
|
|
9
|
+
# Lazy so a client-only install does not need Flask / alyxspu.
|
|
10
|
+
if name in ("serve", "create_app"):
|
|
11
|
+
from . import server
|
|
12
|
+
return getattr(server, name)
|
|
13
|
+
raise AttributeError(name)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""openspuruntime command line."""
|
|
2
|
+
import argparse
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from .client import Runtime, SPUError
|
|
9
|
+
|
|
10
|
+
OUT_DIR = "openspu_outputs"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _rt():
|
|
14
|
+
return Runtime.from_session()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load_samples(args):
|
|
18
|
+
if args.code:
|
|
19
|
+
text = args.code
|
|
20
|
+
elif args.file == "-":
|
|
21
|
+
text = sys.stdin.read()
|
|
22
|
+
elif args.file:
|
|
23
|
+
with open(args.file) as f:
|
|
24
|
+
text = f.read()
|
|
25
|
+
else:
|
|
26
|
+
raise SPUError("give a JSON file, '-' for stdin, or -c '<json>'")
|
|
27
|
+
return [(s[0], s[1]) for s in json.loads(text)]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _train(rt, epochs, lr, quiet=False):
|
|
31
|
+
step = max(1, epochs // 20)
|
|
32
|
+
last = [0]
|
|
33
|
+
|
|
34
|
+
def show(e, loss):
|
|
35
|
+
last[0] = e
|
|
36
|
+
if not quiet and (e % step == 0 or e == epochs):
|
|
37
|
+
print("epoch %d loss %.6f" % (e, loss))
|
|
38
|
+
|
|
39
|
+
history = rt.train(epochs, lr, on_loss=show)
|
|
40
|
+
if history:
|
|
41
|
+
os.makedirs(OUT_DIR, exist_ok=True)
|
|
42
|
+
path = os.path.join(OUT_DIR, "loss.csv")
|
|
43
|
+
with open(path, "w", newline="") as f:
|
|
44
|
+
w = csv.writer(f)
|
|
45
|
+
w.writerow(["epoch", "loss"])
|
|
46
|
+
w.writerows(history)
|
|
47
|
+
try:
|
|
48
|
+
import matplotlib
|
|
49
|
+
matplotlib.use("Agg")
|
|
50
|
+
import matplotlib.pyplot as plt
|
|
51
|
+
plt.plot(*zip(*history))
|
|
52
|
+
plt.xlabel("epoch")
|
|
53
|
+
plt.ylabel("loss")
|
|
54
|
+
plt.savefig(os.path.join(OUT_DIR, "loss.png"))
|
|
55
|
+
plt.close()
|
|
56
|
+
except ImportError:
|
|
57
|
+
pass
|
|
58
|
+
print("saved loss curve to %s/" % OUT_DIR)
|
|
59
|
+
return history
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _shell(rt):
|
|
63
|
+
print("openspuruntime shell. Type an expression to calc it. Commands: info, ping, "
|
|
64
|
+
"build I H O, train EPOCHS LR, run X..., clear, quit")
|
|
65
|
+
while True:
|
|
66
|
+
try:
|
|
67
|
+
line = input("spu> ").strip()
|
|
68
|
+
except (EOFError, KeyboardInterrupt):
|
|
69
|
+
print()
|
|
70
|
+
return
|
|
71
|
+
if not line:
|
|
72
|
+
continue
|
|
73
|
+
cmd, *rest = line.split()
|
|
74
|
+
try:
|
|
75
|
+
if cmd in ("quit", "exit"):
|
|
76
|
+
return
|
|
77
|
+
elif cmd == "info":
|
|
78
|
+
print(rt.info())
|
|
79
|
+
elif cmd == "ping":
|
|
80
|
+
print(rt.ping())
|
|
81
|
+
elif cmd == "build":
|
|
82
|
+
rt.build(*map(int, rest)); print("ok")
|
|
83
|
+
elif cmd == "train":
|
|
84
|
+
_train(rt, int(rest[0]), float(rest[1]))
|
|
85
|
+
elif cmd == "run":
|
|
86
|
+
print(rt.run(map(float, rest)))
|
|
87
|
+
elif cmd == "clear":
|
|
88
|
+
rt.clear_data(); print("ok")
|
|
89
|
+
else:
|
|
90
|
+
print(rt.calc(line))
|
|
91
|
+
except (SPUError, ValueError, IndexError) as e:
|
|
92
|
+
print("error:", e)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_parser():
|
|
96
|
+
p = argparse.ArgumentParser(prog="openspuruntime",
|
|
97
|
+
description="Remote Alyx I100 SPU runtime")
|
|
98
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
99
|
+
|
|
100
|
+
s = sub.add_parser("stream", help="share the SPU plugged into this machine")
|
|
101
|
+
s.add_argument("--allowed_ip", "--allowed-ip", nargs="+", metavar="IP")
|
|
102
|
+
s.add_argument("--allow-any", action="store_true")
|
|
103
|
+
s.add_argument("--serial-port")
|
|
104
|
+
s.add_argument("--baud", type=int, default=115200)
|
|
105
|
+
s.add_argument("--no-tunnel", action="store_true")
|
|
106
|
+
s.add_argument("--install-cloudflared", action="store_true")
|
|
107
|
+
s.add_argument("--tunnel-token")
|
|
108
|
+
s.add_argument("--public-url")
|
|
109
|
+
s.add_argument("--port", type=int, default=9000)
|
|
110
|
+
s.add_argument("--host")
|
|
111
|
+
s.add_argument("--token")
|
|
112
|
+
s.add_argument("--allow-raw", action="store_true")
|
|
113
|
+
s.add_argument("--no-queue", action="store_true")
|
|
114
|
+
s.add_argument("--wsgi", choices=["werkzeug", "waitress"], default="werkzeug")
|
|
115
|
+
s.add_argument("--tls-cert")
|
|
116
|
+
s.add_argument("--tls-key")
|
|
117
|
+
s.add_argument("--public-host")
|
|
118
|
+
|
|
119
|
+
c = sub.add_parser("connect", help="connect to a host")
|
|
120
|
+
c.add_argument("url")
|
|
121
|
+
sub.add_parser("disconnect")
|
|
122
|
+
sub.add_parser("info")
|
|
123
|
+
sub.add_parser("ping")
|
|
124
|
+
sub.add_parser("shell")
|
|
125
|
+
|
|
126
|
+
k = sub.add_parser("calc"); k.add_argument("expr")
|
|
127
|
+
b = sub.add_parser("build")
|
|
128
|
+
for n in ("inputs", "hidden", "outputs"):
|
|
129
|
+
b.add_argument("--" + n, type=int, required=True)
|
|
130
|
+
d = sub.add_parser("data", help="upload a training set")
|
|
131
|
+
d.add_argument("file", nargs="?")
|
|
132
|
+
d.add_argument("-c", "--code")
|
|
133
|
+
sub.add_parser("clear")
|
|
134
|
+
t = sub.add_parser("train")
|
|
135
|
+
t.add_argument("--epochs", type=int, default=3000)
|
|
136
|
+
t.add_argument("--lr", type=float, default=0.5)
|
|
137
|
+
r = sub.add_parser("run"); r.add_argument("inputs", nargs="+", type=float)
|
|
138
|
+
return p
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main(argv=None):
|
|
142
|
+
a = build_parser().parse_args(argv)
|
|
143
|
+
try:
|
|
144
|
+
if a.cmd == "stream":
|
|
145
|
+
from .server import serve
|
|
146
|
+
allowed = ["all"] if a.allow_any else a.allowed_ip
|
|
147
|
+
serve(serial_port=a.serial_port, allowed_ip=allowed, token=a.token, host=a.host,
|
|
148
|
+
port=a.port, tunnel=not a.no_tunnel, tunnel_token=a.tunnel_token,
|
|
149
|
+
public_url=a.public_url, public_host=a.public_host,
|
|
150
|
+
install_cloudflared=a.install_cloudflared, allow_raw=a.allow_raw,
|
|
151
|
+
wait=not a.no_queue, baud=a.baud, wsgi=a.wsgi,
|
|
152
|
+
tls_cert=a.tls_cert, tls_key=a.tls_key)
|
|
153
|
+
elif a.cmd == "connect":
|
|
154
|
+
rt = Runtime.connect(a.url)
|
|
155
|
+
print("connected to", rt.base_url)
|
|
156
|
+
print("SPU:", rt.info())
|
|
157
|
+
elif a.cmd == "disconnect":
|
|
158
|
+
Runtime.disconnect(); print("disconnected")
|
|
159
|
+
elif a.cmd == "info":
|
|
160
|
+
print(_rt().info())
|
|
161
|
+
elif a.cmd == "ping":
|
|
162
|
+
print(_rt().ping())
|
|
163
|
+
elif a.cmd == "calc":
|
|
164
|
+
print(_rt().calc(a.expr))
|
|
165
|
+
elif a.cmd == "build":
|
|
166
|
+
_rt().build(a.inputs, a.hidden, a.outputs); print("ok")
|
|
167
|
+
elif a.cmd == "data":
|
|
168
|
+
s = _load_samples(a); _rt().set_data(s); print("uploaded %d samples" % len(s))
|
|
169
|
+
elif a.cmd == "clear":
|
|
170
|
+
_rt().clear_data(); print("ok")
|
|
171
|
+
elif a.cmd == "train":
|
|
172
|
+
_train(_rt(), a.epochs, a.lr)
|
|
173
|
+
elif a.cmd == "run":
|
|
174
|
+
print(_rt().run(a.inputs))
|
|
175
|
+
elif a.cmd == "shell":
|
|
176
|
+
_shell(_rt())
|
|
177
|
+
except SPUError as e:
|
|
178
|
+
sys.exit("error: %s" % e)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
main()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Client for a remote OpenSPURuntime host."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from urllib.parse import parse_qs, urlparse
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
SESSION_FILE = os.path.join(os.path.expanduser("~"), ".openspuruntime", "session.json")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SPUError(Exception):
|
|
12
|
+
"""Raised for device, server or connection errors."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _split(url):
|
|
16
|
+
u = urlparse(url)
|
|
17
|
+
token = parse_qs(u.query).get("token", [""])[0]
|
|
18
|
+
if not u.scheme or not u.netloc:
|
|
19
|
+
raise SPUError("invalid URL: %s" % url)
|
|
20
|
+
return "%s://%s" % (u.scheme, u.netloc), token
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Runtime:
|
|
24
|
+
def __init__(self, base_url, token, timeout=60):
|
|
25
|
+
self.base_url, self.token, self.timeout = base_url.rstrip("/"), token, timeout
|
|
26
|
+
|
|
27
|
+
# --- connection -------------------------------------------------
|
|
28
|
+
@classmethod
|
|
29
|
+
def connect(cls, url, save=True):
|
|
30
|
+
base, token = _split(url)
|
|
31
|
+
rt = cls(base, token)
|
|
32
|
+
rt.info() # verifies URL, IP gate and token
|
|
33
|
+
if save:
|
|
34
|
+
os.makedirs(os.path.dirname(SESSION_FILE), exist_ok=True)
|
|
35
|
+
fd = os.open(SESSION_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
36
|
+
with os.fdopen(fd, "w") as f:
|
|
37
|
+
json.dump({"base_url": base, "token": token}, f)
|
|
38
|
+
return rt
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_session(cls):
|
|
42
|
+
try:
|
|
43
|
+
with open(SESSION_FILE) as f:
|
|
44
|
+
s = json.load(f)
|
|
45
|
+
except (OSError, ValueError):
|
|
46
|
+
raise SPUError("not connected. Run: openspuruntime connect <url>")
|
|
47
|
+
return cls(s["base_url"], s["token"])
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def disconnect():
|
|
51
|
+
try:
|
|
52
|
+
os.remove(SESSION_FILE)
|
|
53
|
+
except OSError:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
# --- plumbing ---------------------------------------------------
|
|
57
|
+
def _req(self, method, path, payload=None, stream=False):
|
|
58
|
+
try:
|
|
59
|
+
r = requests.request(method, self.base_url + path, json=payload, stream=stream,
|
|
60
|
+
headers={"Authorization": "Bearer " + self.token},
|
|
61
|
+
timeout=None if stream else self.timeout)
|
|
62
|
+
except requests.RequestException as e:
|
|
63
|
+
raise SPUError("connection failed: %s" % e)
|
|
64
|
+
if r.status_code >= 400:
|
|
65
|
+
try:
|
|
66
|
+
msg = r.json().get("error", r.text)
|
|
67
|
+
except ValueError:
|
|
68
|
+
msg = r.text[:200]
|
|
69
|
+
raise SPUError("%s (HTTP %d)" % (msg, r.status_code))
|
|
70
|
+
return r
|
|
71
|
+
|
|
72
|
+
def _json(self, method, path, payload=None):
|
|
73
|
+
return self._req(method, path, payload).json()
|
|
74
|
+
|
|
75
|
+
# --- SPU API (mirrors alyxspu.AlyxSPU) --------------------------
|
|
76
|
+
def ping(self):
|
|
77
|
+
return self._json("GET", "/api/ping").get("pong")
|
|
78
|
+
|
|
79
|
+
def info(self):
|
|
80
|
+
return self._json("GET", "/api/info")["info"]
|
|
81
|
+
|
|
82
|
+
def calc(self, expr):
|
|
83
|
+
return self._json("POST", "/api/calc", {"expr": expr})["result"]
|
|
84
|
+
|
|
85
|
+
def build(self, inputs, hidden, outputs):
|
|
86
|
+
self._json("POST", "/api/build", {"inputs": inputs, "hidden": hidden, "outputs": outputs})
|
|
87
|
+
|
|
88
|
+
def set_data(self, samples):
|
|
89
|
+
self._json("POST", "/api/data", {"samples": [[list(x), list(y)] for x, y in samples]})
|
|
90
|
+
|
|
91
|
+
def add_sample(self, x, y):
|
|
92
|
+
self._json("POST", "/api/data/add", {"x": list(x), "y": list(y)})
|
|
93
|
+
|
|
94
|
+
def clear_data(self):
|
|
95
|
+
self._json("POST", "/api/data/clear")
|
|
96
|
+
|
|
97
|
+
def run(self, inputs):
|
|
98
|
+
return self._json("POST", "/api/run", {"inputs": list(inputs)})["outputs"]
|
|
99
|
+
|
|
100
|
+
def command(self, line):
|
|
101
|
+
return self._json("POST", "/api/command", {"line": line})["reply"]
|
|
102
|
+
|
|
103
|
+
def train(self, epochs, lr, on_loss=None):
|
|
104
|
+
r = self._req("POST", "/api/train", {"epochs": epochs, "lr": lr}, stream=True)
|
|
105
|
+
history = []
|
|
106
|
+
for line in r.iter_lines(decode_unicode=True):
|
|
107
|
+
if not line:
|
|
108
|
+
continue
|
|
109
|
+
msg = json.loads(line)
|
|
110
|
+
if "error" in msg:
|
|
111
|
+
raise SPUError(msg["error"])
|
|
112
|
+
if "epoch" in msg:
|
|
113
|
+
history.append((msg["epoch"], msg["loss"]))
|
|
114
|
+
if on_loss:
|
|
115
|
+
on_loss(msg["epoch"], msg["loss"])
|
|
116
|
+
return history
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Flask server that exposes an Alyx I100 SPU as a small JSON API."""
|
|
2
|
+
import hmac
|
|
3
|
+
import ipaddress
|
|
4
|
+
import json
|
|
5
|
+
import queue
|
|
6
|
+
import secrets
|
|
7
|
+
import threading
|
|
8
|
+
|
|
9
|
+
from flask import Flask, Response, jsonify, request, stream_with_context
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_allowed(values):
|
|
13
|
+
"""Return a list of networks, or None meaning 'any IP'."""
|
|
14
|
+
nets = []
|
|
15
|
+
for v in values or []:
|
|
16
|
+
if str(v).lower() in ("all", "any", "*"):
|
|
17
|
+
return None
|
|
18
|
+
nets.append(ipaddress.ip_network(str(v), strict=False))
|
|
19
|
+
return nets
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ip_ok(ip_str, nets):
|
|
23
|
+
if nets is None:
|
|
24
|
+
return True
|
|
25
|
+
try:
|
|
26
|
+
ip = ipaddress.ip_address(ip_str.strip())
|
|
27
|
+
except ValueError:
|
|
28
|
+
return False
|
|
29
|
+
if getattr(ip, "ipv4_mapped", None):
|
|
30
|
+
ip = ip.ipv4_mapped
|
|
31
|
+
return any(ip in n for n in nets)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _err(msg, code):
|
|
35
|
+
return jsonify({"error": msg}), code
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def create_app(spu, token, allowed_ips=None, tunnel=True, allow_raw=False, wait=True):
|
|
39
|
+
"""Build the Flask app.
|
|
40
|
+
|
|
41
|
+
spu an open alyxspu.AlyxSPU
|
|
42
|
+
allowed_ips list of networks from parse_allowed(), or None for any IP
|
|
43
|
+
tunnel True: trust CF-Connecting-IP only (cloudflared hits us from loopback)
|
|
44
|
+
False: use the socket address, loopback always allowed
|
|
45
|
+
"""
|
|
46
|
+
app = Flask("openspuruntime")
|
|
47
|
+
lock = threading.Lock()
|
|
48
|
+
|
|
49
|
+
@app.before_request
|
|
50
|
+
def gate():
|
|
51
|
+
if tunnel:
|
|
52
|
+
ip = request.headers.get("CF-Connecting-IP")
|
|
53
|
+
if not ip:
|
|
54
|
+
return _err("refused: request did not come through the tunnel", 403)
|
|
55
|
+
ok = _ip_ok(ip, allowed_ips)
|
|
56
|
+
else:
|
|
57
|
+
ip = request.remote_addr or ""
|
|
58
|
+
ok = _ip_ok(ip, allowed_ips) or _ip_ok(ip, [ipaddress.ip_network("127.0.0.0/8"),
|
|
59
|
+
ipaddress.ip_network("::1/128")])
|
|
60
|
+
if not ok:
|
|
61
|
+
return _err("access denied for IP %s. Ask the host to add it with "
|
|
62
|
+
"--allowed_ip %s" % (ip, ip), 403)
|
|
63
|
+
auth = request.headers.get("Authorization", "")
|
|
64
|
+
supplied = auth[7:] if auth.lower().startswith("bearer ") else request.args.get("token", "")
|
|
65
|
+
if not hmac.compare_digest(supplied.encode(), token.encode()):
|
|
66
|
+
return _err("invalid or missing token", 401)
|
|
67
|
+
|
|
68
|
+
@app.after_request
|
|
69
|
+
def no_reuse(resp):
|
|
70
|
+
resp.headers["Connection"] = "close" # one vetted connection is never reused
|
|
71
|
+
return resp
|
|
72
|
+
|
|
73
|
+
def body():
|
|
74
|
+
return request.get_json(silent=True) or {}
|
|
75
|
+
|
|
76
|
+
def device(fn):
|
|
77
|
+
if not lock.acquire(blocking=wait):
|
|
78
|
+
return _err("busy: the SPU is in use", 409)
|
|
79
|
+
try:
|
|
80
|
+
return jsonify(fn())
|
|
81
|
+
except (ValueError, KeyError, TypeError) as e:
|
|
82
|
+
return _err("bad request: %s" % e, 400)
|
|
83
|
+
except Exception as e: # AlyxError, serial errors, ...
|
|
84
|
+
return _err(str(e), 502)
|
|
85
|
+
finally:
|
|
86
|
+
lock.release()
|
|
87
|
+
|
|
88
|
+
def samples_from(raw):
|
|
89
|
+
out = []
|
|
90
|
+
for s in raw:
|
|
91
|
+
x, y = s
|
|
92
|
+
out.append(([float(v) for v in x], [float(v) for v in y]))
|
|
93
|
+
if not out:
|
|
94
|
+
raise ValueError("no samples")
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
@app.get("/api/ping")
|
|
98
|
+
def ping():
|
|
99
|
+
return device(lambda: {"ok": True, "pong": spu.ping()})
|
|
100
|
+
|
|
101
|
+
@app.get("/api/info")
|
|
102
|
+
def info():
|
|
103
|
+
return device(lambda: {"info": spu.info()})
|
|
104
|
+
|
|
105
|
+
@app.post("/api/calc")
|
|
106
|
+
def calc():
|
|
107
|
+
expr = body()["expr"]
|
|
108
|
+
return device(lambda: {"result": spu.calc(str(expr))})
|
|
109
|
+
|
|
110
|
+
@app.post("/api/build")
|
|
111
|
+
def build():
|
|
112
|
+
b = body()
|
|
113
|
+
i, h, o = int(b["inputs"]), int(b["hidden"]), int(b["outputs"])
|
|
114
|
+
return device(lambda: (spu.build(i, h, o), {"ok": True})[1])
|
|
115
|
+
|
|
116
|
+
@app.post("/api/data")
|
|
117
|
+
def data():
|
|
118
|
+
s = samples_from(body()["samples"])
|
|
119
|
+
return device(lambda: (spu.set_data(s), {"ok": True, "count": len(s)})[1])
|
|
120
|
+
|
|
121
|
+
@app.post("/api/data/add")
|
|
122
|
+
def data_add():
|
|
123
|
+
b = body()
|
|
124
|
+
x, y = [float(v) for v in b["x"]], [float(v) for v in b["y"]]
|
|
125
|
+
return device(lambda: (spu.add_sample(x, y), {"ok": True})[1])
|
|
126
|
+
|
|
127
|
+
@app.post("/api/data/clear")
|
|
128
|
+
def data_clear():
|
|
129
|
+
return device(lambda: (spu.clear_data(), {"ok": True})[1])
|
|
130
|
+
|
|
131
|
+
@app.post("/api/run")
|
|
132
|
+
def run():
|
|
133
|
+
x = [float(v) for v in body()["inputs"]]
|
|
134
|
+
return device(lambda: {"outputs": spu.run(x)})
|
|
135
|
+
|
|
136
|
+
@app.post("/api/command")
|
|
137
|
+
def command():
|
|
138
|
+
if not allow_raw:
|
|
139
|
+
return _err("raw commands are disabled (start the host with --allow-raw)", 403)
|
|
140
|
+
line = str(body()["line"])
|
|
141
|
+
return device(lambda: {"reply": spu.command(line)})
|
|
142
|
+
|
|
143
|
+
@app.post("/api/train")
|
|
144
|
+
def train():
|
|
145
|
+
try:
|
|
146
|
+
b = body()
|
|
147
|
+
epochs, lr = int(b["epochs"]), float(b["lr"])
|
|
148
|
+
except (KeyError, ValueError, TypeError) as e:
|
|
149
|
+
return _err("bad request: %s" % e, 400)
|
|
150
|
+
if not lock.acquire(blocking=wait):
|
|
151
|
+
return _err("busy: the SPU is in use", 409)
|
|
152
|
+
|
|
153
|
+
q = queue.Queue()
|
|
154
|
+
|
|
155
|
+
def worker():
|
|
156
|
+
try:
|
|
157
|
+
spu.train(epochs=epochs, lr=lr,
|
|
158
|
+
on_loss=lambda e, l: q.put({"epoch": e, "loss": l}))
|
|
159
|
+
q.put({"done": True})
|
|
160
|
+
except Exception as e:
|
|
161
|
+
q.put({"error": str(e)})
|
|
162
|
+
finally:
|
|
163
|
+
lock.release() # released by the worker, even if the client disconnects
|
|
164
|
+
q.put(None)
|
|
165
|
+
|
|
166
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
167
|
+
|
|
168
|
+
def gen():
|
|
169
|
+
while True:
|
|
170
|
+
item = q.get()
|
|
171
|
+
if item is None:
|
|
172
|
+
return
|
|
173
|
+
yield json.dumps(item) + "\n"
|
|
174
|
+
|
|
175
|
+
return Response(stream_with_context(gen()), mimetype="application/x-ndjson")
|
|
176
|
+
|
|
177
|
+
return app
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def serve(serial_port=None, allowed_ip=None, token=None, host=None, port=9000,
|
|
181
|
+
tunnel=True, tunnel_token=None, public_url=None, public_host=None,
|
|
182
|
+
install_cloudflared=False, allow_raw=False, wait=True, baud=115200,
|
|
183
|
+
wsgi="werkzeug", tls_cert=None, tls_key=None, banner=True):
|
|
184
|
+
"""Open the SPU, start the Flask server and (optionally) a Cloudflare tunnel."""
|
|
185
|
+
from alyxspu import AlyxSPU
|
|
186
|
+
from . import tunnel as tun
|
|
187
|
+
|
|
188
|
+
nets = parse_allowed(allowed_ip)
|
|
189
|
+
if tunnel and allowed_ip is None:
|
|
190
|
+
raise SystemExit("--allowed_ip is required in tunnel mode (use 'all' to allow any IP)")
|
|
191
|
+
token = token or secrets.token_urlsafe(24)
|
|
192
|
+
host = host or ("127.0.0.1" if tunnel else "0.0.0.0")
|
|
193
|
+
|
|
194
|
+
kwargs = {} if baud == 115200 else {"baud": baud}
|
|
195
|
+
try:
|
|
196
|
+
# AlyxSPU opens the port in its constructor
|
|
197
|
+
spu = AlyxSPU(serial_port, **kwargs) if serial_port else AlyxSPU(**kwargs)
|
|
198
|
+
except Exception as e:
|
|
199
|
+
raise SystemExit("could not open the SPU: %s\nPass the port with --serial-port (e.g. COM5)." % e)
|
|
200
|
+
app = create_app(spu, token, nets, tunnel=tunnel, allow_raw=allow_raw, wait=wait)
|
|
201
|
+
|
|
202
|
+
proc = None
|
|
203
|
+
try:
|
|
204
|
+
if tunnel:
|
|
205
|
+
proc, url = tun.start(port, tunnel_token, public_url, install_cloudflared)
|
|
206
|
+
else:
|
|
207
|
+
scheme = "https" if tls_cert else "http"
|
|
208
|
+
url = "%s://%s:%d" % (scheme, public_host or host, port)
|
|
209
|
+
if banner:
|
|
210
|
+
print("\nSPU connected. Run this on the client:\n")
|
|
211
|
+
print(" openspuruntime connect %s/?token=%s\n" % (url.rstrip("/"), token))
|
|
212
|
+
if allowed_ip and nets is not None:
|
|
213
|
+
print("Allowed IPs: %s" % ", ".join(str(n) for n in nets))
|
|
214
|
+
elif nets is None:
|
|
215
|
+
print("Allowing ANY IP - the token is your only protection.")
|
|
216
|
+
print("Press Ctrl+C to stop.\n")
|
|
217
|
+
|
|
218
|
+
if wsgi == "waitress":
|
|
219
|
+
from waitress import serve as wserve
|
|
220
|
+
wserve(app, host=host, port=port, threads=8)
|
|
221
|
+
else:
|
|
222
|
+
ssl_ctx = (tls_cert, tls_key) if tls_cert and tls_key else None
|
|
223
|
+
app.run(host=host, port=port, threaded=True, ssl_context=ssl_ctx)
|
|
224
|
+
except KeyboardInterrupt:
|
|
225
|
+
pass
|
|
226
|
+
finally:
|
|
227
|
+
if proc:
|
|
228
|
+
proc.terminate()
|
|
229
|
+
try:
|
|
230
|
+
spu.close()
|
|
231
|
+
except Exception:
|
|
232
|
+
pass
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Start a Cloudflare tunnel with cloudflared."""
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import stat
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tarfile
|
|
10
|
+
import tempfile
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import urllib.request
|
|
14
|
+
|
|
15
|
+
BIN_DIR = os.path.join(os.path.expanduser("~"), ".openspuruntime", "bin")
|
|
16
|
+
BASE = "https://github.com/cloudflare/cloudflared/releases/latest/download/"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _asset():
|
|
20
|
+
sysname = platform.system().lower()
|
|
21
|
+
m = platform.machine().lower()
|
|
22
|
+
arch = "arm64" if m in ("arm64", "aarch64") else "arm" if m.startswith("arm") else \
|
|
23
|
+
"amd64" if m in ("x86_64", "amd64") else "386"
|
|
24
|
+
if sysname == "windows":
|
|
25
|
+
return "cloudflared-windows-%s.exe" % arch
|
|
26
|
+
if sysname == "darwin":
|
|
27
|
+
return "cloudflared-darwin-%s.tgz" % arch
|
|
28
|
+
return "cloudflared-linux-%s" % arch
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def find():
|
|
32
|
+
p = shutil.which("cloudflared")
|
|
33
|
+
if p:
|
|
34
|
+
return p
|
|
35
|
+
local = os.path.join(BIN_DIR, "cloudflared.exe" if os.name == "nt" else "cloudflared")
|
|
36
|
+
return local if os.path.exists(local) else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def install():
|
|
40
|
+
os.makedirs(BIN_DIR, exist_ok=True)
|
|
41
|
+
asset = _asset()
|
|
42
|
+
dest = os.path.join(BIN_DIR, "cloudflared.exe" if os.name == "nt" else "cloudflared")
|
|
43
|
+
print("Downloading cloudflared (%s)..." % asset)
|
|
44
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
45
|
+
path = os.path.join(tmp, asset)
|
|
46
|
+
urllib.request.urlretrieve(BASE + asset, path)
|
|
47
|
+
if asset.endswith(".tgz"):
|
|
48
|
+
with tarfile.open(path) as t:
|
|
49
|
+
t.extract("cloudflared", tmp)
|
|
50
|
+
path = os.path.join(tmp, "cloudflared")
|
|
51
|
+
shutil.copy(path, dest)
|
|
52
|
+
os.chmod(dest, os.stat(dest).st_mode | stat.S_IEXEC)
|
|
53
|
+
return dest
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def start(port, tunnel_token=None, public_url=None, auto_install=False):
|
|
57
|
+
"""Return (process, public_url)."""
|
|
58
|
+
exe = find()
|
|
59
|
+
if not exe:
|
|
60
|
+
ok = auto_install
|
|
61
|
+
if not ok and sys.stdin.isatty():
|
|
62
|
+
ok = input("cloudflared not found. Download the official binary? [y/N] ").lower().startswith("y")
|
|
63
|
+
if not ok:
|
|
64
|
+
raise SystemExit("cloudflared is required. Install it or pass --install-cloudflared.")
|
|
65
|
+
exe = install()
|
|
66
|
+
|
|
67
|
+
if tunnel_token:
|
|
68
|
+
if not public_url:
|
|
69
|
+
raise SystemExit("--public-url is required with --tunnel-token")
|
|
70
|
+
proc = subprocess.Popen([exe, "tunnel", "--no-autoupdate", "run", "--token", tunnel_token],
|
|
71
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
72
|
+
return proc, public_url
|
|
73
|
+
|
|
74
|
+
proc = subprocess.Popen([exe, "tunnel", "--no-autoupdate", "--url", "http://127.0.0.1:%d" % port],
|
|
75
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
|
|
76
|
+
found = {}
|
|
77
|
+
|
|
78
|
+
def reader():
|
|
79
|
+
for line in proc.stderr:
|
|
80
|
+
m = re.search(r"https://[a-z0-9-]+\.trycloudflare\.com", line)
|
|
81
|
+
if m and "url" not in found:
|
|
82
|
+
found["url"] = m.group(0)
|
|
83
|
+
|
|
84
|
+
threading.Thread(target=reader, daemon=True).start()
|
|
85
|
+
for _ in range(300):
|
|
86
|
+
if "url" in found:
|
|
87
|
+
return proc, found["url"]
|
|
88
|
+
if proc.poll() is not None:
|
|
89
|
+
break
|
|
90
|
+
time.sleep(0.1)
|
|
91
|
+
proc.terminate()
|
|
92
|
+
raise SystemExit("could not start the Cloudflare quick tunnel")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openspuruntime
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Remote Alyx I100 SPU runtime: a Flask server tunneled with Cloudflare
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: requests
|
|
9
|
+
Provides-Extra: host
|
|
10
|
+
Requires-Dist: flask; extra == "host"
|
|
11
|
+
Requires-Dist: alyxspu; extra == "host"
|
|
12
|
+
Provides-Extra: waitress
|
|
13
|
+
Requires-Dist: waitress; extra == "waitress"
|
|
14
|
+
|
|
15
|
+
# OpenSPURuntime
|
|
16
|
+
|
|
17
|
+
Use any other computer's Alyx I100 SPU as your own. The host runs a plain Flask
|
|
18
|
+
server in front of the `alyxspu` driver and tunnels it with Cloudflare, so no
|
|
19
|
+
port forwarding or public IP is needed.
|
|
20
|
+
|
|
21
|
+
## Installation :
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
pip install "openspuruntime[host]" # on the machine with the SPU
|
|
25
|
+
pip install openspuruntime # on the client
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Streaming :
|
|
29
|
+
```
|
|
30
|
+
openspuruntime stream --serial-port COM5 --allowed_ip all
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Connecting :
|
|
34
|
+
```
|
|
35
|
+
openspuruntime connect https://random-words.trycloudflare.com/?token=...
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage :
|
|
39
|
+
#### For CLI :
|
|
40
|
+
```
|
|
41
|
+
openspuruntime calc "sqrt(2)*3 + sin(pi/2)"
|
|
42
|
+
openspuruntime build --inputs 2 --hidden 4 --outputs 1
|
|
43
|
+
openspuruntime data -c "[[[0,0],[0]],[[0,1],[1]],[[1,0],[1]],[[1,1],[0]]]"
|
|
44
|
+
openspuruntime train --epochs 3000 --lr 0.5
|
|
45
|
+
openspuruntime run 1 0
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
#### In python :
|
|
49
|
+
```python
|
|
50
|
+
from openspuruntime import Runtime
|
|
51
|
+
rt = Runtime.connect("https://random-words.trycloudflare.com/?token=...")
|
|
52
|
+
print(rt.calc("2+2"))
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Disconnecting
|
|
56
|
+
```
|
|
57
|
+
openspuruntime disconnect
|
|
58
|
+
```
|
|
59
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
openspuruntime/__init__.py
|
|
4
|
+
openspuruntime/__main__.py
|
|
5
|
+
openspuruntime/cli.py
|
|
6
|
+
openspuruntime/client.py
|
|
7
|
+
openspuruntime/server.py
|
|
8
|
+
openspuruntime/tunnel.py
|
|
9
|
+
openspuruntime.egg-info/PKG-INFO
|
|
10
|
+
openspuruntime.egg-info/SOURCES.txt
|
|
11
|
+
openspuruntime.egg-info/dependency_links.txt
|
|
12
|
+
openspuruntime.egg-info/entry_points.txt
|
|
13
|
+
openspuruntime.egg-info/requires.txt
|
|
14
|
+
openspuruntime.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
openspuruntime
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "openspuruntime"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Remote Alyx I100 SPU runtime: a Flask server tunneled with Cloudflare"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = ["requests"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
host = ["flask", "alyxspu"]
|
|
16
|
+
waitress = ["waitress"]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
openspuruntime = "openspuruntime.cli:main"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools]
|
|
22
|
+
packages = ["openspuruntime"]
|