spatial-computing 0.5.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.
- spatial/__init__.py +6 -0
- spatial/ai/__init__.py +18 -0
- spatial/ai/kvcache.py +81 -0
- spatial/cli.py +135 -0
- spatial/compute/__init__.py +3 -0
- spatial/compute/cost.py +69 -0
- spatial/compute/detect.py +139 -0
- spatial/compute/monitor.py +111 -0
- spatial/compute/scheduler.py +168 -0
- spatial/compute/taskqueue.py +107 -0
- spatial/compute/worker.py +89 -0
- spatial/compute/workloads.py +148 -0
- spatial/core/__init__.py +1 -0
- spatial/core/energy.py +101 -0
- spatial/core/ledger.py +76 -0
- spatial/core/model.py +82 -0
- spatial/core/protocol.py +167 -0
- spatial/core/registry.py +59 -0
- spatial/holographic/__init__.py +8 -0
- spatial/holographic/cgh.py +40 -0
- spatial/holographic/model.py +49 -0
- spatial/intelligence/__init__.py +6 -0
- spatial/intelligence/predictor.py +51 -0
- spatial/memory/__init__.py +5 -0
- spatial/memory/fararray.py +44 -0
- spatial/memory/pageserver.py +141 -0
- spatial/memory/secure.py +100 -0
- spatial/memory/shared.py +43 -0
- spatial/memory/tiered.py +355 -0
- spatial/q/__init__.py +4 -0
- spatial/q/__main__.py +9 -0
- spatial/q/backends/__init__.py +122 -0
- spatial/q/codegen.py +153 -0
- spatial/q/compiler.py +276 -0
- spatial/q/ir.py +121 -0
- spatial/q/lexer.py +61 -0
- spatial/q/parser.py +330 -0
- spatial/q/qir.py +128 -0
- spatial/q/runtime.py +274 -0
- spatial/q/sim.py +218 -0
- spatial/runtime/__init__.py +3 -0
- spatial/runtime/envs.py +74 -0
- spatial/runtime/inspect.py +48 -0
- spatial/runtime/microvm.py +125 -0
- spatial/runtime/planner.py +155 -0
- spatial/runtime/runner.py +108 -0
- spatial/runtime/toolchain.py +35 -0
- spatial/scientific/__init__.py +8 -0
- spatial/scientific/jobs.py +80 -0
- spatial/simulation/__init__.py +7 -0
- spatial/simulation/heat.py +48 -0
- spatial/simulation/nbody.py +31 -0
- spatial_computing-0.5.0.dist-info/METADATA +112 -0
- spatial_computing-0.5.0.dist-info/RECORD +58 -0
- spatial_computing-0.5.0.dist-info/WHEEL +5 -0
- spatial_computing-0.5.0.dist-info/entry_points.txt +3 -0
- spatial_computing-0.5.0.dist-info/licenses/LICENSE +202 -0
- spatial_computing-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Linux micro-VM runner (QEMU). Boots a kernel + generated initramfs containing a static
|
|
2
|
+
Linux ELF, runs it, captures its output from the serial console, powers off.
|
|
3
|
+
|
|
4
|
+
accel="hvf" : hardware virtualization (same ISA as host) -> near-native CPU speed
|
|
5
|
+
accel="tcg" : full software emulation -> the cost of cross-ISA execution
|
|
6
|
+
|
|
7
|
+
No network device, no disk, no host folder is attached to the guest: it only sees the
|
|
8
|
+
files packed in its initramfs. Guest RAM is a hard cap (-m).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import gzip
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
VENDOR = Path(__file__).resolve().parents[2] / "vendor"
|
|
22
|
+
KERNEL = VENDOR / "vmlinuz-virt"
|
|
23
|
+
BUSYBOX = VENDOR / "busybox-x86_64"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _cpio_entry(name: str, mode: int, data: bytes = b"", rdev: tuple[int, int] = (0, 0), ino: int = 0) -> bytes:
|
|
27
|
+
n = name.encode() + b"\0"
|
|
28
|
+
hdr = "070701" + "".join(f"{v:08X}" for v in (
|
|
29
|
+
ino, mode, 0, 0, 1, 0, len(data), 0, 0, rdev[0], rdev[1], len(n), 0))
|
|
30
|
+
out = hdr.encode() + n
|
|
31
|
+
out += b"\0" * (-len(out) % 4)
|
|
32
|
+
out += data + b"\0" * (-len(data) % 4)
|
|
33
|
+
return out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_initramfs(app: bytes, args: list[str], out_path: str, files: dict | None = None,
|
|
37
|
+
app_name: str = "app") -> int:
|
|
38
|
+
"""newc cpio archive written directly (no root needed for the device node)."""
|
|
39
|
+
init = "\n".join([
|
|
40
|
+
"#!/bin/sh",
|
|
41
|
+
"/bin/busybox mount -t devtmpfs dev /dev 2>/dev/null",
|
|
42
|
+
"/bin/busybox mount -t proc proc /proc 2>/dev/null",
|
|
43
|
+
"echo SPATIAL_BEGIN",
|
|
44
|
+
f"/{app_name} " + " ".join(args),
|
|
45
|
+
"echo SPATIAL_EXIT $?",
|
|
46
|
+
"/bin/busybox poweroff -f",
|
|
47
|
+
""]).encode()
|
|
48
|
+
S_DIR, S_REG, S_CHR, S_LNK = 0o040755, 0o100755, 0o020600, 0o120777
|
|
49
|
+
parts = [
|
|
50
|
+
_cpio_entry("bin", S_DIR, ino=1), _cpio_entry("dev", S_DIR, ino=2), _cpio_entry("proc", S_DIR, ino=3),
|
|
51
|
+
_cpio_entry("dev/console", S_CHR, rdev=(5, 1), ino=4),
|
|
52
|
+
_cpio_entry("bin/busybox", S_REG, BUSYBOX.read_bytes(), ino=5),
|
|
53
|
+
_cpio_entry("bin/sh", S_LNK, b"busybox", ino=6),
|
|
54
|
+
_cpio_entry("init", S_REG, init, ino=7),
|
|
55
|
+
_cpio_entry(app_name, S_REG, app, ino=8),
|
|
56
|
+
_cpio_entry("data", S_DIR, ino=9),
|
|
57
|
+
]
|
|
58
|
+
for i, (fname, content) in enumerate((files or {}).items()):
|
|
59
|
+
if "/" in fname or fname in ("", ".", ".."):
|
|
60
|
+
raise ValueError(f"input file name must be a plain name, got {fname!r}")
|
|
61
|
+
parts.append(_cpio_entry(f"data/{fname}", 0o100444, content, ino=10 + i))
|
|
62
|
+
parts.append(_cpio_entry("TRAILER!!!", 0))
|
|
63
|
+
blob = gzip.compress(b"".join(parts), compresslevel=1)
|
|
64
|
+
Path(out_path).write_bytes(blob)
|
|
65
|
+
return len(blob)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class VmResult:
|
|
70
|
+
ok: bool
|
|
71
|
+
accel: str
|
|
72
|
+
stdout: str # what the app printed
|
|
73
|
+
exit_code: int | None
|
|
74
|
+
total_wall_s: float # QEMU start -> QEMU exit (boot + app + shutdown)
|
|
75
|
+
boot_s: float | None # QEMU start -> app start marker
|
|
76
|
+
raw_tail: str = ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def available() -> dict:
|
|
80
|
+
return {"qemu": shutil.which("qemu-system-x86_64"), "kernel": KERNEL.exists(), "busybox": BUSYBOX.exists()}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def run_elf(elf_path: str, args: list[str] | None = None, accel: str = "hvf", mem_mb: int = 256,
|
|
84
|
+
cpus: int = 1, timeout_s: float = 180.0, files: dict | None = None,
|
|
85
|
+
app_name: str = "app") -> VmResult:
|
|
86
|
+
"""`files` ({name: bytes}) are copied read-only into the guest under /data. They are the
|
|
87
|
+
only host data the guest can see; nothing is written back to the host."""
|
|
88
|
+
qemu = shutil.which("qemu-system-x86_64")
|
|
89
|
+
if not (qemu and KERNEL.exists() and BUSYBOX.exists()):
|
|
90
|
+
raise RuntimeError(f"micro-VM prerequisites missing: {available()}")
|
|
91
|
+
with tempfile.TemporaryDirectory(prefix="spatial-vm-") as td:
|
|
92
|
+
initrd = os.path.join(td, "initramfs.cpio.gz")
|
|
93
|
+
build_initramfs(Path(elf_path).read_bytes(), args or [], initrd, files, app_name)
|
|
94
|
+
cmd = [qemu, "-accel", accel, "-cpu", "host" if accel == "hvf" else "max",
|
|
95
|
+
"-m", str(mem_mb), "-smp", str(cpus), "-kernel", str(KERNEL), "-initrd", initrd,
|
|
96
|
+
"-append", "console=ttyS0 quiet panic=-1 rdinit=/init",
|
|
97
|
+
"-nodefaults", "-no-reboot", "-display", "none", "-serial", "stdio"]
|
|
98
|
+
t0 = time.perf_counter()
|
|
99
|
+
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, text=True,
|
|
100
|
+
errors="replace")
|
|
101
|
+
lines, boot_s, code, capture, app_out = [], None, None, False, []
|
|
102
|
+
try:
|
|
103
|
+
for line in p.stdout:
|
|
104
|
+
line = line.rstrip("\r\n")
|
|
105
|
+
lines.append(line)
|
|
106
|
+
if line.strip() == "SPATIAL_BEGIN":
|
|
107
|
+
boot_s = time.perf_counter() - t0
|
|
108
|
+
capture = True
|
|
109
|
+
elif line.startswith("SPATIAL_EXIT"):
|
|
110
|
+
code = int(line.split()[1])
|
|
111
|
+
capture = False
|
|
112
|
+
elif capture:
|
|
113
|
+
app_out.append(line)
|
|
114
|
+
if time.perf_counter() - t0 > timeout_s:
|
|
115
|
+
break
|
|
116
|
+
p.wait(timeout=max(1.0, timeout_s - (time.perf_counter() - t0)))
|
|
117
|
+
except subprocess.TimeoutExpired:
|
|
118
|
+
pass
|
|
119
|
+
finally:
|
|
120
|
+
if p.poll() is None:
|
|
121
|
+
p.kill()
|
|
122
|
+
p.wait()
|
|
123
|
+
total = time.perf_counter() - t0
|
|
124
|
+
return VmResult(ok=code == 0, accel=accel, stdout="\n".join(app_out), exit_code=code,
|
|
125
|
+
total_wall_s=total, boot_s=boot_s, raw_tail="\n".join(lines[-15:]))
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Spatial Runtime Engine planner + executor.
|
|
2
|
+
|
|
3
|
+
plan(artifact, host): ordered list of execution routes, each marked feasible or not WITH the
|
|
4
|
+
reason. execute(): runs the first feasible route and returns the app output and timings.
|
|
5
|
+
No route is reported as feasible unless its prerequisites were detected on this host.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import microvm
|
|
17
|
+
from .inspect import Artifact, inspect
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Route:
|
|
22
|
+
name: str
|
|
23
|
+
feasible: bool
|
|
24
|
+
reason: str
|
|
25
|
+
expected_overhead: str = ""
|
|
26
|
+
isolation: str = ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Execution:
|
|
31
|
+
route: str
|
|
32
|
+
ok: bool
|
|
33
|
+
stdout: str
|
|
34
|
+
wall_s: float
|
|
35
|
+
extra: dict = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def find_wine() -> str | None:
|
|
39
|
+
"""Wine on PATH, or a user-level unpacked build under ~/.local/share/spatial (no system install)."""
|
|
40
|
+
w = shutil.which("wine64") or shutil.which("wine")
|
|
41
|
+
if w:
|
|
42
|
+
return w
|
|
43
|
+
base = Path.home() / ".local" / "share" / "spatial"
|
|
44
|
+
for cand in sorted(base.glob("**/bin/wine")) if base.exists() else []:
|
|
45
|
+
if os.access(cand, os.X_OK):
|
|
46
|
+
return str(cand)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def host_caps(node=None) -> dict:
|
|
51
|
+
import platform
|
|
52
|
+
caps = {"os": {"Darwin": "macos", "Linux": "linux", "Windows": "windows"}.get(platform.system(), "?"),
|
|
53
|
+
"arch": {"AMD64": "x86_64", "arm64": "aarch64"}.get(platform.machine(), platform.machine()),
|
|
54
|
+
"node": shutil.which("node"), "wine": find_wine(),
|
|
55
|
+
"docker": shutil.which("docker") or shutil.which("podman"),
|
|
56
|
+
"qemu_x86": shutil.which("qemu-system-x86_64"),
|
|
57
|
+
"microvm": all(microvm.available().values())}
|
|
58
|
+
try:
|
|
59
|
+
caps["hvf"] = subprocess.run(["sysctl", "-n", "kern.hv_support"], capture_output=True,
|
|
60
|
+
text=True, timeout=5).stdout.strip() == "1"
|
|
61
|
+
except Exception:
|
|
62
|
+
caps["hvf"] = False
|
|
63
|
+
return caps
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def plan(art: Artifact, caps: dict | None = None) -> list[Route]:
|
|
67
|
+
caps = caps or host_caps()
|
|
68
|
+
R: list[Route] = []
|
|
69
|
+
same_arch = caps["arch"] in art.arch or ("arm64" in art.arch and caps["arch"] == "aarch64")
|
|
70
|
+
if art.format == "macho":
|
|
71
|
+
ok = caps["os"] == "macos" and same_arch
|
|
72
|
+
R.append(Route("native", ok, "same OS and CPU" if ok else f"host is {caps['os']}/{caps['arch']}, binary is macOS/{art.arch}",
|
|
73
|
+
"none", "none (host process)"))
|
|
74
|
+
elif art.format == "elf":
|
|
75
|
+
if caps["os"] == "linux" and same_arch:
|
|
76
|
+
R.append(Route("native", True, "same OS and CPU", "none", "none"))
|
|
77
|
+
hv = caps["microvm"] and caps["hvf"] and "x86_64" in art.arch and caps["arch"] == "x86_64"
|
|
78
|
+
R.append(Route("microvm-hvf", hv,
|
|
79
|
+
"Linux x86_64 guest under Hypervisor.framework" if hv else
|
|
80
|
+
"needs QEMU + kernel + HVF and an x86_64 guest on an x86_64 host",
|
|
81
|
+
"VM boot (~1-2 s) + near-native CPU", "hardware VM, no network, no host files"))
|
|
82
|
+
tcg = caps["microvm"] and "x86_64" in art.arch
|
|
83
|
+
R.append(Route("microvm-tcg", tcg,
|
|
84
|
+
"software emulation (works without HVF or across CPU families)" if tcg else
|
|
85
|
+
"no kernel/emulator available for this guest architecture",
|
|
86
|
+
"5-30x slower CPU", "emulated VM"))
|
|
87
|
+
R.append(Route("container", bool(caps["docker"]) and same_arch,
|
|
88
|
+
"OCI runtime found" if caps["docker"] else "no Docker/Podman installed",
|
|
89
|
+
"Linux VM shared by containers on macOS", "namespace isolation inside a VM"))
|
|
90
|
+
elif art.format == "pe":
|
|
91
|
+
R.append(Route("native", caps["os"] == "windows" and same_arch,
|
|
92
|
+
"same OS" if caps["os"] == "windows" else "host is not Windows", "none", "none"))
|
|
93
|
+
R.append(Route("wine", bool(caps["wine"]),
|
|
94
|
+
"Wine found" if caps["wine"] else
|
|
95
|
+
"Wine is not installed (not installed automatically: unsigned third-party build, needs user consent)",
|
|
96
|
+
"API translation, no CPU emulation on x86_64", "host process (weak)"))
|
|
97
|
+
R.append(Route("windows-vm", False,
|
|
98
|
+
"no Windows image/licence on this machine; would run under QEMU+HVF",
|
|
99
|
+
"full guest OS (>=4 GB RAM, >=20 GB disk)", "hardware VM"))
|
|
100
|
+
R.append(Route("remote-windows", False, "no remote Windows host configured", "network latency", "remote machine"))
|
|
101
|
+
elif art.format == "wasm":
|
|
102
|
+
ok = bool(caps["node"])
|
|
103
|
+
R.append(Route("wasm-node-wasi", ok, "Node.js WASI runtime found" if ok else "no WASM runtime",
|
|
104
|
+
"JIT, typically 1.1-2x native", "WASI sandbox (no files/network unless granted)"))
|
|
105
|
+
elif art.format == "script":
|
|
106
|
+
R.append(Route("native-interpreter", True, f"interpreter: {art.detail}", "none", "none"))
|
|
107
|
+
else:
|
|
108
|
+
R.append(Route("none", False, "unrecognised format"))
|
|
109
|
+
return R
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_WASI_JS = """
|
|
113
|
+
const { WASI } = require('node:wasi'); const fs = require('node:fs');
|
|
114
|
+
process.removeAllListeners('warning');
|
|
115
|
+
const wasi = new WASI({ version: 'preview1', args: ['app', ...process.argv.slice(3)], env: {} });
|
|
116
|
+
(async () => {
|
|
117
|
+
const m = await WebAssembly.compile(fs.readFileSync(process.argv[2]));
|
|
118
|
+
const i = await WebAssembly.instantiate(m, wasi.getImportObject());
|
|
119
|
+
wasi.start(i);
|
|
120
|
+
})();
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def execute(path: str, args: list[str] | None = None, route: str | None = None, timeout_s: float = 300) -> Execution:
|
|
125
|
+
art = inspect(path)
|
|
126
|
+
routes = plan(art)
|
|
127
|
+
chosen = next((r for r in routes if r.feasible and (route is None or r.name == route)), None)
|
|
128
|
+
if chosen is None:
|
|
129
|
+
why = "; ".join(f"{r.name}: {r.reason}" for r in routes)
|
|
130
|
+
return Execution(route or "none", False, "", 0.0, {"not_runnable": why, "format": art.format})
|
|
131
|
+
args = args or []
|
|
132
|
+
t0 = time.perf_counter()
|
|
133
|
+
if chosen.name in ("native", "native-interpreter"):
|
|
134
|
+
r = subprocess.run([path, *args], capture_output=True, text=True, timeout=timeout_s)
|
|
135
|
+
return Execution(chosen.name, r.returncode == 0, r.stdout.strip(), time.perf_counter() - t0)
|
|
136
|
+
if chosen.name.startswith("microvm-"):
|
|
137
|
+
v = microvm.run_elf(path, args, accel=chosen.name.split("-")[1], timeout_s=timeout_s)
|
|
138
|
+
return Execution(chosen.name, v.ok, v.stdout.strip(), v.total_wall_s,
|
|
139
|
+
{"boot_s": v.boot_s, "exit_code": v.exit_code, "tail": "" if v.ok else v.raw_tail})
|
|
140
|
+
if chosen.name == "wasm-node-wasi":
|
|
141
|
+
js = Path(os.environ.get("TMPDIR", "/tmp")) / f"spatial-wasi-{os.getpid()}.js"
|
|
142
|
+
js.write_text(_WASI_JS)
|
|
143
|
+
try:
|
|
144
|
+
r = subprocess.run(["node", "--no-warnings", str(js), path, *args], capture_output=True,
|
|
145
|
+
text=True, timeout=timeout_s)
|
|
146
|
+
finally:
|
|
147
|
+
js.unlink(missing_ok=True)
|
|
148
|
+
return Execution(chosen.name, r.returncode == 0, r.stdout.strip(), time.perf_counter() - t0,
|
|
149
|
+
{"stderr": r.stderr[-300:]})
|
|
150
|
+
if chosen.name == "wine":
|
|
151
|
+
env = {**os.environ, "WINEDEBUG": "-all", "WINEPREFIX": str(Path.home() / ".local/share/spatial/wineprefix")}
|
|
152
|
+
r = subprocess.run([find_wine(), path, *args], capture_output=True, text=True, timeout=timeout_s, env=env)
|
|
153
|
+
return Execution(chosen.name, r.returncode == 0, r.stdout.strip(), time.perf_counter() - t0,
|
|
154
|
+
{"stderr": r.stderr[-300:]})
|
|
155
|
+
return Execution(chosen.name, False, "", 0.0, {"not_runnable": "route has no executor yet"})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Remote execution: a runner that accepts a Linux ELF over SWP/1 and runs it ONLY inside a
|
|
2
|
+
micro-VM (no network, no disk, no host files, RAM capped). This is the one place where
|
|
3
|
+
code crosses the wire, which is why the sandbox is not optional and cannot be disabled
|
|
4
|
+
by the caller.
|
|
5
|
+
|
|
6
|
+
Run: spatial serve runtime --token-file <path>
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import secrets
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ..core.protocol import RpcClient, RpcServer
|
|
18
|
+
from . import microvm
|
|
19
|
+
from .inspect import inspect
|
|
20
|
+
|
|
21
|
+
MAX_ELF = 64 * 1024 * 1024
|
|
22
|
+
MAX_TIMEOUT = 300.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _handler(h: dict, blob: bytes):
|
|
26
|
+
op = h.get("op")
|
|
27
|
+
if op == "ping":
|
|
28
|
+
return {"pong": True, "microvm": microvm.available()}, b""
|
|
29
|
+
if op != "run_elf":
|
|
30
|
+
raise ValueError(f"unknown op {op!r}")
|
|
31
|
+
if len(blob) > MAX_ELF:
|
|
32
|
+
raise ValueError("binary too large")
|
|
33
|
+
args = [str(a) for a in h.get("args", [])]
|
|
34
|
+
if any(c in a for a in args for c in "\n;&|`$<>"):
|
|
35
|
+
raise ValueError("argument contains shell metacharacters")
|
|
36
|
+
with tempfile.NamedTemporaryFile(prefix="spatial-remote-", suffix=".elf", delete=False) as f:
|
|
37
|
+
f.write(blob)
|
|
38
|
+
path = f.name
|
|
39
|
+
try:
|
|
40
|
+
art = inspect(path)
|
|
41
|
+
if art.format != "elf" or "x86_64" not in art.arch:
|
|
42
|
+
raise ValueError(f"runner only accepts Linux x86_64 ELF binaries, got {art.format}/{art.arch}")
|
|
43
|
+
accel = "hvf" if h.get("accel", "hvf") == "hvf" else "tcg"
|
|
44
|
+
r = microvm.run_elf(path, args, accel=accel, mem_mb=min(int(h.get("mem_mb", 256)), 1024),
|
|
45
|
+
timeout_s=min(float(h.get("timeout_s", 120)), MAX_TIMEOUT))
|
|
46
|
+
finally:
|
|
47
|
+
os.unlink(path)
|
|
48
|
+
return {"run_ok": r.ok, "exit_code": r.exit_code, "stdout": r.stdout, "boot_s": r.boot_s,
|
|
49
|
+
"total_wall_s": r.total_wall_s, "accel": r.accel}, b""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def serve(token: bytes, host: str = "127.0.0.1", port: int = 0, allow_remote: bool = False) -> RpcServer:
|
|
53
|
+
return RpcServer(_handler, token, host=host, port=port, allow_remote=allow_remote)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run_remote(client: RpcClient, elf_path: str, args: list[str] | None = None, **opts) -> dict:
|
|
57
|
+
rh, _ = client.call({"op": "run_elf", "args": args or [], **opts}, Path(elf_path).read_bytes())
|
|
58
|
+
return rh
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class LocalRunnerProcess:
|
|
62
|
+
"""Runner in a separate OS process on loopback (stand-in for a remote host)."""
|
|
63
|
+
|
|
64
|
+
def __init__(self):
|
|
65
|
+
self.token = secrets.token_bytes(32)
|
|
66
|
+
self._tf = Path(os.environ.get("TMPDIR", "/tmp")) / f"spatial-rt-{os.getpid()}-{secrets.token_hex(4)}"
|
|
67
|
+
self._tf.write_bytes(self.token)
|
|
68
|
+
os.chmod(self._tf, 0o600)
|
|
69
|
+
self.proc = subprocess.Popen([sys.executable, "-m", "spatial.runtime.runner", "--token-file", str(self._tf)],
|
|
70
|
+
cwd=Path(__file__).resolve().parents[2], stdout=subprocess.PIPE, text=True)
|
|
71
|
+
line = self.proc.stdout.readline()
|
|
72
|
+
if not line.startswith("PORT "):
|
|
73
|
+
raise RuntimeError(f"runner failed to start: {line!r}")
|
|
74
|
+
self.port = int(line.split()[1])
|
|
75
|
+
|
|
76
|
+
def client(self) -> RpcClient:
|
|
77
|
+
return RpcClient("127.0.0.1", self.port, self.token, timeout=MAX_TIMEOUT + 30)
|
|
78
|
+
|
|
79
|
+
def stop(self):
|
|
80
|
+
self.proc.terminate()
|
|
81
|
+
try:
|
|
82
|
+
self.proc.wait(timeout=5)
|
|
83
|
+
except subprocess.TimeoutExpired:
|
|
84
|
+
self.proc.kill()
|
|
85
|
+
self._tf.unlink(missing_ok=True)
|
|
86
|
+
|
|
87
|
+
def __enter__(self):
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
def __exit__(self, *a):
|
|
91
|
+
self.stop()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main():
|
|
95
|
+
import argparse
|
|
96
|
+
ap = argparse.ArgumentParser()
|
|
97
|
+
ap.add_argument("--token-file", required=True)
|
|
98
|
+
ap.add_argument("--host", default="127.0.0.1")
|
|
99
|
+
ap.add_argument("--port", type=int, default=0)
|
|
100
|
+
ap.add_argument("--allow-remote", action="store_true")
|
|
101
|
+
a = ap.parse_args()
|
|
102
|
+
srv = serve(Path(a.token_file).read_bytes(), a.host, a.port, a.allow_remote)
|
|
103
|
+
print(f"PORT {srv.port}", flush=True)
|
|
104
|
+
srv.serve_forever()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
main()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Cross-compilation of one C source to several executable formats (zig cc from PyPI `ziglang`).
|
|
2
|
+
Used to produce REAL foreign binaries for the runtime tests instead of mock files."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
TARGETS = {
|
|
10
|
+
"macos-x86_64": (None, "bench-macos"), # host clang
|
|
11
|
+
"linux-x86_64": ("x86_64-linux-musl", "bench-linux.elf"),
|
|
12
|
+
"windows-x86_64": ("x86_64-windows-gnu", "bench-windows.exe"),
|
|
13
|
+
"wasm32-wasi": ("wasm32-wasi", "bench.wasm"),
|
|
14
|
+
}
|
|
15
|
+
FLAGS = ["-O2", "-ffp-contract=off"] # no FMA contraction: identical IEEE results everywhere
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build(src: str, out_dir: str, target: str) -> Path:
|
|
19
|
+
triple, name = TARGETS[target]
|
|
20
|
+
out = Path(out_dir) / name
|
|
21
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
if triple is None:
|
|
23
|
+
cmd = ["clang", *FLAGS, src, "-o", str(out)]
|
|
24
|
+
else:
|
|
25
|
+
cmd = [sys.executable, "-m", "ziglang", "cc", "-target", triple, *FLAGS, src, "-o", str(out)]
|
|
26
|
+
if "linux" in triple:
|
|
27
|
+
cmd.insert(-2, "-static")
|
|
28
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
|
|
29
|
+
if r.returncode != 0:
|
|
30
|
+
raise RuntimeError(f"build {target} failed: {r.stderr[-800:]}")
|
|
31
|
+
return out
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_all(src: str, out_dir: str) -> dict[str, Path]:
|
|
35
|
+
return {t: build(src, out_dir, t) for t in TARGETS}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Spatial Scientific & Industrial Engine — FIRST COMPONENTS ONLY.
|
|
2
|
+
|
|
3
|
+
A JobSpec describes a numerical job with a validation target; the engine runs it and
|
|
4
|
+
checks the answer against an independent reference. One reference job is implemented
|
|
5
|
+
(Monte Carlo option pricing vs Black-Scholes) plus an RK4 ODE job checked against its
|
|
6
|
+
analytic solution. No industrial solver is claimed.
|
|
7
|
+
"""
|
|
8
|
+
from .jobs import JobSpec, run_job, european_call_mc, black_scholes_call, harmonic_rk4 # noqa: F401
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from ..compute.monitor import Measure
|
|
10
|
+
from ..core.model import Measurement
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def black_scholes_call(s0: float, k: float, r: float, sigma: float, t: float) -> float:
|
|
14
|
+
d1 = (math.log(s0 / k) + (r + sigma * sigma / 2) * t) / (sigma * math.sqrt(t))
|
|
15
|
+
d2 = d1 - sigma * math.sqrt(t)
|
|
16
|
+
cdf = lambda x: 0.5 * (1 + math.erf(x / math.sqrt(2)))
|
|
17
|
+
return s0 * cdf(d1) - k * math.exp(-r * t) * cdf(d2)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def european_call_mc(s0: float, k: float, r: float, sigma: float, t: float, paths: int,
|
|
21
|
+
seed: int = 0) -> tuple[float, float]:
|
|
22
|
+
"""Returns (price, standard error). Antithetic variates."""
|
|
23
|
+
rng = np.random.default_rng(seed)
|
|
24
|
+
acc = acc2 = 0.0
|
|
25
|
+
n = 0
|
|
26
|
+
left = paths // 2
|
|
27
|
+
drift, vol = (r - sigma * sigma / 2) * t, sigma * math.sqrt(t)
|
|
28
|
+
disc = math.exp(-r * t)
|
|
29
|
+
while left > 0:
|
|
30
|
+
m = min(left, 1_000_000)
|
|
31
|
+
z = rng.standard_normal(m)
|
|
32
|
+
pay = 0.5 * disc * (np.maximum(s0 * np.exp(drift + vol * z) - k, 0)
|
|
33
|
+
+ np.maximum(s0 * np.exp(drift - vol * z) - k, 0))
|
|
34
|
+
acc += float(pay.sum())
|
|
35
|
+
acc2 += float((pay * pay).sum())
|
|
36
|
+
n += m
|
|
37
|
+
left -= m
|
|
38
|
+
mean = acc / n
|
|
39
|
+
return mean, math.sqrt(max(acc2 / n - mean * mean, 0) / n)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def harmonic_rk4(omega: float, t_end: float, steps: int) -> tuple[float, float]:
|
|
43
|
+
"""x'' = -omega^2 x, x(0)=1, v(0)=0, integrated with RK4. Returns (x(t_end), error bound).
|
|
44
|
+
The bound is the RK4 global error scale (omega*dt)^4 * omega*t_end, used as the tolerance."""
|
|
45
|
+
dt = t_end / steps
|
|
46
|
+
x, v = 1.0, 0.0
|
|
47
|
+
f = lambda x, v: (v, -omega * omega * x)
|
|
48
|
+
for _ in range(steps):
|
|
49
|
+
k1 = f(x, v)
|
|
50
|
+
k2 = f(x + dt / 2 * k1[0], v + dt / 2 * k1[1])
|
|
51
|
+
k3 = f(x + dt / 2 * k2[0], v + dt / 2 * k2[1])
|
|
52
|
+
k4 = f(x + dt * k3[0], v + dt * k3[1])
|
|
53
|
+
x += dt / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0])
|
|
54
|
+
v += dt / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1])
|
|
55
|
+
return x, (omega * dt) ** 4 * omega * t_end
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class JobSpec:
|
|
60
|
+
name: str
|
|
61
|
+
sector: str
|
|
62
|
+
fn: Callable[[], tuple[float, float]] # returns (value, standard error)
|
|
63
|
+
reference: float | None = None
|
|
64
|
+
tolerance_sigmas: float = 4.0
|
|
65
|
+
tags: list[str] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run_job(job: JobSpec) -> Measurement:
|
|
69
|
+
out = {}
|
|
70
|
+
with Measure("scientific", job.name, "local_numpy", power=False) as m:
|
|
71
|
+
out["v"], out["se"] = job.fn()
|
|
72
|
+
if m.ok:
|
|
73
|
+
m.metrics.update(value=out["v"], std_error=out["se"], reference=job.reference)
|
|
74
|
+
if job.reference is not None:
|
|
75
|
+
dev = abs(out["v"] - job.reference) / max(out["se"], 1e-300)
|
|
76
|
+
m.metrics["deviation_sigmas"] = dev
|
|
77
|
+
m.ok = dev <= job.tolerance_sigmas
|
|
78
|
+
if not m.ok:
|
|
79
|
+
m.notes = "validation against reference failed"
|
|
80
|
+
return m
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Spatial Simulation Engine — FIRST COMPONENTS ONLY.
|
|
2
|
+
|
|
3
|
+
An explicit 2-D heat-diffusion solver (NumPy and Numba variants producing the same field)
|
|
4
|
+
and a symplectic N-body integrator. No multiphysics, no digital twin.
|
|
5
|
+
"""
|
|
6
|
+
from .heat import step_numpy, step_numba, simulate, stable_dt # noqa: F401
|
|
7
|
+
from . import nbody # noqa: F401
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Explicit finite-difference heat equation u_t = alpha * laplacian(u), insulated borders."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from numba import njit
|
|
8
|
+
HAVE_NUMBA = True
|
|
9
|
+
except Exception: # pragma: no cover
|
|
10
|
+
HAVE_NUMBA = False
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def stable_dt(alpha: float, dx: float) -> float:
|
|
14
|
+
"""Largest stable time step for the explicit scheme in 2-D (CFL): dx^2 / (4 alpha)."""
|
|
15
|
+
return dx * dx / (4 * alpha)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def step_numpy(u: np.ndarray, c: float) -> np.ndarray:
|
|
19
|
+
"""c = alpha*dt/dx^2 (must be <= 0.25). Neumann (zero-flux) boundaries via edge padding."""
|
|
20
|
+
p = np.pad(u, 1, mode="edge")
|
|
21
|
+
return u + c * (p[:-2, 1:-1] + p[2:, 1:-1] + p[1:-1, :-2] + p[1:-1, 2:] - 4 * u)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if HAVE_NUMBA:
|
|
25
|
+
@njit(cache=True)
|
|
26
|
+
def step_numba(u, c):
|
|
27
|
+
n, m = u.shape
|
|
28
|
+
out = np.empty_like(u)
|
|
29
|
+
for i in range(n):
|
|
30
|
+
for j in range(m):
|
|
31
|
+
up = u[i - 1, j] if i > 0 else u[i, j]
|
|
32
|
+
dn = u[i + 1, j] if i < n - 1 else u[i, j]
|
|
33
|
+
lf = u[i, j - 1] if j > 0 else u[i, j]
|
|
34
|
+
rt = u[i, j + 1] if j < m - 1 else u[i, j]
|
|
35
|
+
out[i, j] = u[i, j] + c * (up + dn + lf + rt - 4 * u[i, j])
|
|
36
|
+
return out
|
|
37
|
+
else: # pragma: no cover
|
|
38
|
+
step_numba = step_numpy
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def simulate(u0: np.ndarray, c: float, steps: int, backend: str = "numpy") -> np.ndarray:
|
|
42
|
+
if c > 0.25:
|
|
43
|
+
raise ValueError("unstable: c = alpha*dt/dx^2 must be <= 0.25")
|
|
44
|
+
f = step_numba if backend == "numba" else step_numpy
|
|
45
|
+
u = u0.astype(np.float64)
|
|
46
|
+
for _ in range(steps):
|
|
47
|
+
u = f(u, c)
|
|
48
|
+
return u
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Gravitational N-body with a leapfrog (kick-drift-kick) integrator. Symplectic: total
|
|
2
|
+
energy oscillates but does not drift, which the test checks."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def accelerations(pos: np.ndarray, mass: np.ndarray, soft: float = 1e-3) -> np.ndarray:
|
|
9
|
+
d = pos[None, :, :] - pos[:, None, :]
|
|
10
|
+
r2 = (d ** 2).sum(-1) + soft ** 2
|
|
11
|
+
np.fill_diagonal(r2, np.inf)
|
|
12
|
+
return (d * (mass[None, :, None] / r2[..., None] ** 1.5)).sum(1)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def energy(pos, vel, mass, soft: float = 1e-3) -> float:
|
|
16
|
+
kin = 0.5 * (mass * (vel ** 2).sum(-1)).sum()
|
|
17
|
+
d = pos[None] - pos[:, None]
|
|
18
|
+
r = np.sqrt((d ** 2).sum(-1) + soft ** 2)
|
|
19
|
+
iu = np.triu_indices(len(mass), 1)
|
|
20
|
+
return float(kin - (mass[iu[0]] * mass[iu[1]] / r[iu]).sum())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def leapfrog(pos, vel, mass, dt: float, steps: int, soft: float = 1e-3):
|
|
24
|
+
pos, vel = pos.copy(), vel.copy()
|
|
25
|
+
a = accelerations(pos, mass, soft)
|
|
26
|
+
for _ in range(steps):
|
|
27
|
+
vel += 0.5 * dt * a
|
|
28
|
+
pos += dt * vel
|
|
29
|
+
a = accelerations(pos, mass, soft)
|
|
30
|
+
vel += 0.5 * dt * a
|
|
31
|
+
return pos, vel
|