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,168 @@
|
|
|
1
|
+
"""Strategy comparison and selection for the Spatial Compute Engine.
|
|
2
|
+
|
|
3
|
+
compare(): run every feasible strategy for a task, MEASURE it, verify the result
|
|
4
|
+
matches the reference, then choose() by objective (time | cpu | energy).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import multiprocessing as mp
|
|
9
|
+
import threading
|
|
10
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Callable
|
|
13
|
+
|
|
14
|
+
from ..core.ledger import Ledger
|
|
15
|
+
from ..core.model import Measurement, Node, ResourceKind
|
|
16
|
+
from . import workloads
|
|
17
|
+
from .monitor import Measure
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _chunk(args):
|
|
21
|
+
name, params = args
|
|
22
|
+
return workloads.run(name, **params)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _row_chunks(n: int, parts: int) -> list[tuple[int, int]]:
|
|
26
|
+
step = max(1, n // parts)
|
|
27
|
+
return [(r, min(n, r + step)) for r in range(0, n, step)]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Strategy:
|
|
32
|
+
name: str
|
|
33
|
+
fn: Callable[[dict], object]
|
|
34
|
+
kind: str # local | optimized | parallel | distributed | remote
|
|
35
|
+
needs_worker: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ComputeScheduler:
|
|
39
|
+
def __init__(self, node: Node, ledger: Ledger | None = None, worker_client=None):
|
|
40
|
+
self.node = node
|
|
41
|
+
self.ledger = ledger or Ledger()
|
|
42
|
+
self.worker = worker_client
|
|
43
|
+
self.cores = int(node.by_kind(ResourceKind.CPU)[0].capacity)
|
|
44
|
+
self._pool = None
|
|
45
|
+
|
|
46
|
+
# ---- mandelbrot family -------------------------------------------------
|
|
47
|
+
def mandel_strategies(self) -> list[Strategy]:
|
|
48
|
+
S = [
|
|
49
|
+
Strategy("local_python", lambda p: workloads.mandel_python(**p), "local"),
|
|
50
|
+
Strategy("local_numpy", lambda p: workloads.mandel_numpy(**p), "optimized"),
|
|
51
|
+
]
|
|
52
|
+
if workloads.HAVE_NUMBA:
|
|
53
|
+
S += [
|
|
54
|
+
Strategy("local_numba_jit", lambda p: workloads.mandel_numba(**p), "optimized"),
|
|
55
|
+
Strategy("local_numba_threads", lambda p: workloads.mandel_numba_parallel(**p), "parallel"),
|
|
56
|
+
Strategy("multiprocess_numba", self._mp, "parallel"),
|
|
57
|
+
Strategy("multiprocess_warm_pool", self._mp_warm, "parallel"),
|
|
58
|
+
]
|
|
59
|
+
if self.worker is not None:
|
|
60
|
+
S += [
|
|
61
|
+
Strategy("remote_worker", self._remote, "remote", needs_worker=True),
|
|
62
|
+
Strategy("distributed_local+worker", self._distributed, "distributed", needs_worker=True),
|
|
63
|
+
]
|
|
64
|
+
return S
|
|
65
|
+
|
|
66
|
+
def _mp(self, p: dict):
|
|
67
|
+
chunks = _row_chunks(p["n"], self.cores * 8)
|
|
68
|
+
with ProcessPoolExecutor(self.cores, mp_context=mp.get_context("spawn")) as ex:
|
|
69
|
+
return sum(ex.map(_chunk, [("mandel_numba", {**p, "r0": a, "r1": b}) for a, b in chunks]))
|
|
70
|
+
|
|
71
|
+
def _mp_warm(self, p: dict):
|
|
72
|
+
"""Same split as _mp but on a pool started (and JIT-warmed) beforehand: isolates the
|
|
73
|
+
cost of process start-up + imports that _mp pays on every call."""
|
|
74
|
+
if self._pool is None:
|
|
75
|
+
self._start_pool()
|
|
76
|
+
chunks = _row_chunks(p["n"], self.cores * 8)
|
|
77
|
+
return sum(self._pool.map(_chunk, [("mandel_numba", {**p, "r0": a, "r1": b}) for a, b in chunks]))
|
|
78
|
+
|
|
79
|
+
def _start_pool(self):
|
|
80
|
+
self._pool = ProcessPoolExecutor(self.cores, mp_context=mp.get_context("spawn"))
|
|
81
|
+
list(self._pool.map(_chunk, [("mandel_numba", {"n": 8, "iters": 8})] * (self.cores * 4)))
|
|
82
|
+
|
|
83
|
+
def close(self):
|
|
84
|
+
if self._pool is not None:
|
|
85
|
+
self._pool.shutdown()
|
|
86
|
+
self._pool = None
|
|
87
|
+
|
|
88
|
+
def _remote(self, p: dict):
|
|
89
|
+
rh, _ = self.worker.call({"op": "run", "workload": "mandel_numba", "params": p})
|
|
90
|
+
self._last_remote = rh
|
|
91
|
+
return rh["result"]
|
|
92
|
+
|
|
93
|
+
def _distributed(self, p: dict):
|
|
94
|
+
"""Interleave row chunks: odd chunks to the worker, even chunks locally."""
|
|
95
|
+
chunks = _row_chunks(p["n"], 32)
|
|
96
|
+
mine, theirs = chunks[0::2], chunks[1::2]
|
|
97
|
+
out = {}
|
|
98
|
+
|
|
99
|
+
def remote():
|
|
100
|
+
tot = 0
|
|
101
|
+
for a, b in theirs:
|
|
102
|
+
rh, _ = self.worker.call({"op": "run", "workload": "mandel_numba",
|
|
103
|
+
"params": {**p, "r0": a, "r1": b}})
|
|
104
|
+
tot += rh["result"]
|
|
105
|
+
out["r"] = tot
|
|
106
|
+
|
|
107
|
+
t = threading.Thread(target=remote)
|
|
108
|
+
t.start()
|
|
109
|
+
local = sum(workloads.mandel_numba(**{**p, "r0": a, "r1": b}) for a, b in mine)
|
|
110
|
+
t.join()
|
|
111
|
+
return local + out["r"]
|
|
112
|
+
|
|
113
|
+
# ---- generic -----------------------------------------------------------
|
|
114
|
+
def warmup(self):
|
|
115
|
+
"""Compile JIT code outside the timed region (compile time is reported separately)."""
|
|
116
|
+
if workloads.HAVE_NUMBA:
|
|
117
|
+
workloads.mandel_numba(8, 8)
|
|
118
|
+
workloads.mandel_numba_parallel(8, 8)
|
|
119
|
+
if self._pool is None:
|
|
120
|
+
self._start_pool()
|
|
121
|
+
if self.worker is not None:
|
|
122
|
+
self.worker.call({"op": "run", "workload": "mandel_numba", "params": {"n": 8, "iters": 8}})
|
|
123
|
+
|
|
124
|
+
def compare(self, experiment: str, params: dict, strategies: list[Strategy],
|
|
125
|
+
reference: object | None = None, skip: set[str] = frozenset()) -> list[Measurement]:
|
|
126
|
+
rows = []
|
|
127
|
+
for s in strategies:
|
|
128
|
+
if s.name in skip:
|
|
129
|
+
continue
|
|
130
|
+
holder = {}
|
|
131
|
+
with Measure("compute", experiment, s.name) as m:
|
|
132
|
+
holder["r"] = s.fn(dict(params))
|
|
133
|
+
m.metrics["kind"] = s.kind
|
|
134
|
+
m.metrics["params"] = params
|
|
135
|
+
if m.ok:
|
|
136
|
+
if reference is None:
|
|
137
|
+
reference = holder["r"]
|
|
138
|
+
m.metrics["result"] = holder["r"]
|
|
139
|
+
m.metrics["matches_reference"] = holder["r"] == reference
|
|
140
|
+
if not m.metrics["matches_reference"]:
|
|
141
|
+
m.ok = False
|
|
142
|
+
m.notes = "result differs from reference"
|
|
143
|
+
if s.needs_worker and m.ok:
|
|
144
|
+
m.notes = "worker is a separate process on this same machine (loopback)"
|
|
145
|
+
rows.append(self.ledger.record(m))
|
|
146
|
+
return rows
|
|
147
|
+
|
|
148
|
+
def predict_best(self, n: int, iters: int) -> tuple[str, float] | None:
|
|
149
|
+
"""Ask the Intelligence Engine which strategy should be fastest for this size, from the
|
|
150
|
+
ledger's past measurements, WITHOUT running anything. None when history is too thin."""
|
|
151
|
+
from ..intelligence import PowerLawPredictor
|
|
152
|
+
return PowerLawPredictor.from_ledger(self.ledger.rows()).best(n * n * iters)
|
|
153
|
+
|
|
154
|
+
def choose(self, rows: list[Measurement], objective: str = "time") -> Measurement:
|
|
155
|
+
ok = [r for r in rows if r.ok]
|
|
156
|
+
if not ok:
|
|
157
|
+
raise RuntimeError("no strategy succeeded")
|
|
158
|
+
if objective == "cost": # $ = CPU-seconds actually burnt, priced per core-hour
|
|
159
|
+
usd_core_h = getattr(self, "usd_per_core_hour", 0.045) # assumption, override on the instance
|
|
160
|
+
for r in ok:
|
|
161
|
+
r.cost_usd = (r.cpu_s or r.wall_s) / 3600 * usd_core_h
|
|
162
|
+
key = {"time": lambda r: r.wall_s, "cpu": lambda r: r.cpu_s, "cost": lambda r: r.cost_usd,
|
|
163
|
+
"energy": lambda r: r.energy_j if r.energy_j is not None else float("inf")}[objective]
|
|
164
|
+
best = min(ok, key=key)
|
|
165
|
+
self.ledger.decide("compute", f"choose {best.strategy}", f"min {objective}",
|
|
166
|
+
experiment=best.experiment,
|
|
167
|
+
candidates={r.strategy: round(key(r), 4) for r in ok if key(r) != float("inf")})
|
|
168
|
+
return best
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Priority task queue over a local executor slot and N remote workers.
|
|
2
|
+
|
|
3
|
+
Tasks are allow-listed workloads (name + params), so they are safe to re-run: when a
|
|
4
|
+
worker fails mid-task the task is re-queued and the worker is retired. Lower `priority`
|
|
5
|
+
runs first; ties keep submission order.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import heapq
|
|
10
|
+
import itertools
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from concurrent.futures import Future
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
from . import workloads
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(order=True)
|
|
20
|
+
class _Item:
|
|
21
|
+
priority: int
|
|
22
|
+
seq: int
|
|
23
|
+
workload: str = field(compare=False)
|
|
24
|
+
params: dict = field(compare=False)
|
|
25
|
+
future: Future = field(compare=False)
|
|
26
|
+
attempts: int = field(compare=False, default=0)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TaskQueue:
|
|
30
|
+
def __init__(self, workers: dict | None = None, local_slots: int = 1, max_attempts: int = 3):
|
|
31
|
+
"""workers: {name: RpcClient}. local_slots: tasks run concurrently in this process."""
|
|
32
|
+
self._heap: list[_Item] = []
|
|
33
|
+
self._cv = threading.Condition()
|
|
34
|
+
self._seq = itertools.count()
|
|
35
|
+
self._closed = False
|
|
36
|
+
self.max_attempts = max_attempts
|
|
37
|
+
self.log: list[dict] = [] # one entry per finished attempt: who ran what, how long
|
|
38
|
+
self.retired: list[str] = []
|
|
39
|
+
self._threads = []
|
|
40
|
+
for i in range(local_slots):
|
|
41
|
+
self._spawn(f"local-{i}", None)
|
|
42
|
+
for name, client in (workers or {}).items():
|
|
43
|
+
self._spawn(name, client)
|
|
44
|
+
|
|
45
|
+
def _spawn(self, name, client):
|
|
46
|
+
t = threading.Thread(target=self._loop, args=(name, client), daemon=True)
|
|
47
|
+
t.start()
|
|
48
|
+
self._threads.append(t)
|
|
49
|
+
|
|
50
|
+
def submit(self, workload: str, params: dict | None = None, priority: int = 10) -> Future:
|
|
51
|
+
if workload not in workloads.REGISTRY:
|
|
52
|
+
raise KeyError(f"workload '{workload}' is not allow-listed")
|
|
53
|
+
f: Future = Future()
|
|
54
|
+
with self._cv:
|
|
55
|
+
if self._closed:
|
|
56
|
+
raise RuntimeError("queue is closed")
|
|
57
|
+
heapq.heappush(self._heap, _Item(priority, next(self._seq), workload, params or {}, f))
|
|
58
|
+
self._cv.notify()
|
|
59
|
+
return f
|
|
60
|
+
|
|
61
|
+
def _loop(self, name, client):
|
|
62
|
+
while True:
|
|
63
|
+
with self._cv:
|
|
64
|
+
while not self._heap and not self._closed:
|
|
65
|
+
self._cv.wait()
|
|
66
|
+
if not self._heap:
|
|
67
|
+
return
|
|
68
|
+
item = heapq.heappop(self._heap)
|
|
69
|
+
t0 = time.perf_counter()
|
|
70
|
+
try:
|
|
71
|
+
if client is None:
|
|
72
|
+
result = workloads.run(item.workload, **item.params)
|
|
73
|
+
else:
|
|
74
|
+
result = client.call({"op": "run", "workload": item.workload, "params": item.params})[0]["result"]
|
|
75
|
+
except (ConnectionError, OSError) as e: # executor is gone: give the task back
|
|
76
|
+
item.attempts += 1
|
|
77
|
+
self.log.append({"executor": name, "workload": item.workload, "ok": False, "error": str(e)})
|
|
78
|
+
with self._cv:
|
|
79
|
+
self.retired.append(name)
|
|
80
|
+
alive = sum(t.is_alive() for t in self._threads) - 1
|
|
81
|
+
if item.attempts >= self.max_attempts or alive <= 0:
|
|
82
|
+
item.future.set_exception(RuntimeError(f"task failed after {item.attempts} attempt(s): {e}"))
|
|
83
|
+
else:
|
|
84
|
+
heapq.heappush(self._heap, item)
|
|
85
|
+
self._cv.notify()
|
|
86
|
+
return # retire this executor
|
|
87
|
+
except Exception as e: # the task itself is wrong: do not retry
|
|
88
|
+
self.log.append({"executor": name, "workload": item.workload, "ok": False, "error": str(e)})
|
|
89
|
+
item.future.set_exception(e)
|
|
90
|
+
continue
|
|
91
|
+
self.log.append({"executor": name, "workload": item.workload, "priority": item.priority,
|
|
92
|
+
"ok": True, "wall_s": time.perf_counter() - t0})
|
|
93
|
+
item.future.set_result(result)
|
|
94
|
+
|
|
95
|
+
def close(self, wait: bool = True):
|
|
96
|
+
with self._cv:
|
|
97
|
+
self._closed = True
|
|
98
|
+
self._cv.notify_all()
|
|
99
|
+
if wait:
|
|
100
|
+
for t in self._threads:
|
|
101
|
+
t.join(timeout=60)
|
|
102
|
+
|
|
103
|
+
def __enter__(self):
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
def __exit__(self, *a):
|
|
107
|
+
self.close()
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Compute worker: executes whitelisted workloads by name over SWP/0.
|
|
2
|
+
|
|
3
|
+
Run: python -m spatial.compute.worker --token-file <path> [--port 0]
|
|
4
|
+
Binds 127.0.0.1 only. Exposing it on a LAN requires --allow-remote AND --host,
|
|
5
|
+
which is a deliberate, explicit act by the machine owner.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import os
|
|
11
|
+
import secrets
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ..core.protocol import RpcClient, RpcServer
|
|
18
|
+
from . import workloads
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _handler(header: dict, blob: bytes):
|
|
22
|
+
op = header.get("op")
|
|
23
|
+
if op == "ping":
|
|
24
|
+
return {"pong": True, "pid": os.getpid()}, b""
|
|
25
|
+
if op == "run":
|
|
26
|
+
t = os.times()
|
|
27
|
+
c0, w0 = t.user + t.system, time.perf_counter()
|
|
28
|
+
result = workloads.run(header["workload"], **header.get("params", {}))
|
|
29
|
+
t = os.times()
|
|
30
|
+
return {"result": result, "wall_s": time.perf_counter() - w0,
|
|
31
|
+
"cpu_s": t.user + t.system - c0}, b""
|
|
32
|
+
raise ValueError(f"unknown op {op!r}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def serve(token: bytes, host: str = "127.0.0.1", port: int = 0, allow_remote: bool = False) -> RpcServer:
|
|
36
|
+
return RpcServer(_handler, token, host=host, port=port, allow_remote=allow_remote)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LocalWorkerProcess:
|
|
40
|
+
"""Spawns a worker as a separate OS process on loopback (stand-in for a second machine:
|
|
41
|
+
it validates the protocol and its overhead, NOT the gain of extra hardware)."""
|
|
42
|
+
|
|
43
|
+
def __init__(self):
|
|
44
|
+
self.token = secrets.token_bytes(32)
|
|
45
|
+
self._tf = Path(os.environ.get("TMPDIR", "/tmp")) / f"spatial-token-{os.getpid()}-{secrets.token_hex(4)}"
|
|
46
|
+
self._tf.write_bytes(self.token)
|
|
47
|
+
os.chmod(self._tf, 0o600)
|
|
48
|
+
root = Path(__file__).resolve().parents[2]
|
|
49
|
+
self.proc = subprocess.Popen(
|
|
50
|
+
[sys.executable, "-m", "spatial.compute.worker", "--token-file", str(self._tf)],
|
|
51
|
+
cwd=root, stdout=subprocess.PIPE, text=True)
|
|
52
|
+
line = self.proc.stdout.readline()
|
|
53
|
+
if not line.startswith("PORT "):
|
|
54
|
+
raise RuntimeError(f"worker failed to start: {line!r}")
|
|
55
|
+
self.port = int(line.split()[1])
|
|
56
|
+
|
|
57
|
+
def client(self) -> RpcClient:
|
|
58
|
+
return RpcClient("127.0.0.1", self.port, self.token)
|
|
59
|
+
|
|
60
|
+
def stop(self):
|
|
61
|
+
self.proc.terminate()
|
|
62
|
+
try:
|
|
63
|
+
self.proc.wait(timeout=5)
|
|
64
|
+
except subprocess.TimeoutExpired:
|
|
65
|
+
self.proc.kill()
|
|
66
|
+
self._tf.unlink(missing_ok=True)
|
|
67
|
+
|
|
68
|
+
def __enter__(self):
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def __exit__(self, *a):
|
|
72
|
+
self.stop()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main():
|
|
76
|
+
ap = argparse.ArgumentParser()
|
|
77
|
+
ap.add_argument("--token-file", required=True)
|
|
78
|
+
ap.add_argument("--host", default="127.0.0.1")
|
|
79
|
+
ap.add_argument("--port", type=int, default=0)
|
|
80
|
+
ap.add_argument("--allow-remote", action="store_true",
|
|
81
|
+
help="explicit consent to accept non-loopback connections")
|
|
82
|
+
a = ap.parse_args()
|
|
83
|
+
srv = serve(Path(a.token_file).read_bytes(), a.host, a.port, a.allow_remote)
|
|
84
|
+
print(f"PORT {srv.port}", flush=True)
|
|
85
|
+
srv.serve_forever()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
main()
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Whitelisted workloads. Every variant of a workload must return the same checksum,
|
|
2
|
+
so a faster strategy is only accepted if it computes the same thing."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
# workqueue: no TBB/OpenMP dependency, safe with subprocess use from helper threads
|
|
11
|
+
os.environ.setdefault("NUMBA_THREADING_LAYER", "workqueue")
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import numba
|
|
15
|
+
from numba import njit, prange
|
|
16
|
+
HAVE_NUMBA = True
|
|
17
|
+
except Exception: # pragma: no cover
|
|
18
|
+
HAVE_NUMBA = False
|
|
19
|
+
|
|
20
|
+
# Mandelbrot window
|
|
21
|
+
X0, X1, Y0, Y1 = -2.0, 0.6, -1.3, 1.3
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def mandel_python(n: int, iters: int, r0: int = 0, r1: int | None = None) -> int:
|
|
25
|
+
r1 = n if r1 is None else r1
|
|
26
|
+
total = 0
|
|
27
|
+
for i in range(r0, r1):
|
|
28
|
+
ci = Y0 + (Y1 - Y0) * i / n
|
|
29
|
+
for j in range(n):
|
|
30
|
+
cr = X0 + (X1 - X0) * j / n
|
|
31
|
+
zr = zi = 0.0
|
|
32
|
+
k = 0
|
|
33
|
+
while k < iters and zr * zr + zi * zi <= 4.0:
|
|
34
|
+
zr, zi = zr * zr - zi * zi + cr, 2.0 * zr * zi + ci
|
|
35
|
+
k += 1
|
|
36
|
+
total += k
|
|
37
|
+
return total
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def mandel_numpy(n: int, iters: int, r0: int = 0, r1: int | None = None) -> int:
|
|
41
|
+
r1 = n if r1 is None else r1
|
|
42
|
+
ci = (Y0 + (Y1 - Y0) * np.arange(r0, r1) / n)[:, None]
|
|
43
|
+
cr = (X0 + (X1 - X0) * np.arange(n) / n)[None, :]
|
|
44
|
+
c = cr + 1j * ci
|
|
45
|
+
z = np.zeros_like(c)
|
|
46
|
+
count = np.zeros(c.shape, dtype=np.int64)
|
|
47
|
+
alive = np.ones(c.shape, dtype=bool)
|
|
48
|
+
for _ in range(iters):
|
|
49
|
+
alive &= (z.real * z.real + z.imag * z.imag) <= 4.0
|
|
50
|
+
if not alive.any():
|
|
51
|
+
break
|
|
52
|
+
count[alive] += 1
|
|
53
|
+
z[alive] = z[alive] * z[alive] + c[alive]
|
|
54
|
+
return int(count.sum())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if HAVE_NUMBA:
|
|
58
|
+
@njit(cache=True, fastmath=False, nogil=True) # nogil: usable from threads
|
|
59
|
+
def _mandel_nb(n, iters, r0, r1):
|
|
60
|
+
total = 0
|
|
61
|
+
for i in range(r0, r1):
|
|
62
|
+
ci = Y0 + (Y1 - Y0) * i / n
|
|
63
|
+
for j in range(n):
|
|
64
|
+
cr = X0 + (X1 - X0) * j / n
|
|
65
|
+
zr = 0.0
|
|
66
|
+
zi = 0.0
|
|
67
|
+
k = 0
|
|
68
|
+
while k < iters and zr * zr + zi * zi <= 4.0:
|
|
69
|
+
t = zr * zr - zi * zi + cr
|
|
70
|
+
zi = 2.0 * zr * zi + ci
|
|
71
|
+
zr = t
|
|
72
|
+
k += 1
|
|
73
|
+
total += k
|
|
74
|
+
return total
|
|
75
|
+
|
|
76
|
+
@njit(cache=True, parallel=True, fastmath=False)
|
|
77
|
+
def _mandel_nb_par(n, iters, r0, r1):
|
|
78
|
+
total = 0
|
|
79
|
+
for i in prange(r0, r1):
|
|
80
|
+
ci = Y0 + (Y1 - Y0) * i / n
|
|
81
|
+
row = 0
|
|
82
|
+
for j in range(n):
|
|
83
|
+
cr = X0 + (X1 - X0) * j / n
|
|
84
|
+
zr = 0.0
|
|
85
|
+
zi = 0.0
|
|
86
|
+
k = 0
|
|
87
|
+
while k < iters and zr * zr + zi * zi <= 4.0:
|
|
88
|
+
t = zr * zr - zi * zi + cr
|
|
89
|
+
zi = 2.0 * zr * zi + ci
|
|
90
|
+
zr = t
|
|
91
|
+
k += 1
|
|
92
|
+
row += k
|
|
93
|
+
total += row
|
|
94
|
+
return total
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def mandel_numba(n: int, iters: int, r0: int = 0, r1: int | None = None) -> int:
|
|
98
|
+
return int(_mandel_nb(n, iters, r0, n if r1 is None else r1))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def mandel_numba_parallel(n: int, iters: int, r0: int = 0, r1: int | None = None) -> int:
|
|
102
|
+
return int(_mandel_nb_par(n, iters, r0, n if r1 is None else r1))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def montecarlo_pi(samples: int, seed: int = 0) -> float:
|
|
106
|
+
rng = np.random.default_rng(seed)
|
|
107
|
+
inside = 0
|
|
108
|
+
left = samples
|
|
109
|
+
while left > 0:
|
|
110
|
+
k = min(left, 2_000_000)
|
|
111
|
+
xy = rng.random((k, 2))
|
|
112
|
+
inside += int(((xy * xy).sum(axis=1) <= 1.0).sum())
|
|
113
|
+
left -= k
|
|
114
|
+
return 4.0 * inside / samples
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def sha256_chain(rounds: int, size: int = 1 << 20) -> str:
|
|
118
|
+
"""Hash-bound workload (memory-bandwidth + SHA extensions if any)."""
|
|
119
|
+
buf = bytes(size)
|
|
120
|
+
h = hashlib.sha256()
|
|
121
|
+
for _ in range(rounds):
|
|
122
|
+
h.update(buf)
|
|
123
|
+
return h.hexdigest()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def matmul(n: int, seed: int = 0) -> float:
|
|
127
|
+
rng = np.random.default_rng(seed)
|
|
128
|
+
a = rng.random((n, n), dtype=np.float32)
|
|
129
|
+
b = rng.random((n, n), dtype=np.float32)
|
|
130
|
+
return float((a @ b).trace())
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
REGISTRY = {
|
|
134
|
+
"mandel_python": mandel_python,
|
|
135
|
+
"mandel_numpy": mandel_numpy,
|
|
136
|
+
"montecarlo_pi": montecarlo_pi,
|
|
137
|
+
"sha256_chain": sha256_chain,
|
|
138
|
+
"matmul": matmul,
|
|
139
|
+
}
|
|
140
|
+
if HAVE_NUMBA:
|
|
141
|
+
REGISTRY["mandel_numba"] = mandel_numba
|
|
142
|
+
REGISTRY["mandel_numba_parallel"] = mandel_numba_parallel
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def run(name: str, **params):
|
|
146
|
+
if name not in REGISTRY:
|
|
147
|
+
raise KeyError(f"workload '{name}' is not whitelisted")
|
|
148
|
+
return REGISTRY[name](**params)
|
spatial/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .model import Resource, Node, Task, Measurement, ResourceKind # noqa: F401
|
spatial/core/energy.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Whole-machine power sampling from the battery gauge (no sudo).
|
|
2
|
+
|
|
3
|
+
MEASURED LIMITATION (MacBookPro14,1, 2026-11-14): the gauge value exposed by ioreg stayed
|
|
4
|
+
constant (32.5 W) for 24 s across idle -> full CPU load -> idle. Its refresh period is far
|
|
5
|
+
longer than a benchmark run, so per-run energy from this source is only
|
|
6
|
+
"last gauge reading x duration". `PowerSampler.static` flags that case; callers must not
|
|
7
|
+
derive marginal (above-idle) energy from it. Proper per-run energy needs `powermetrics`
|
|
8
|
+
(root) or an external power meter.
|
|
9
|
+
|
|
10
|
+
Only valid on battery power: on AC the gauge reports charge current, not system
|
|
11
|
+
draw, so `available()` is False and energy is reported as None (never guessed).
|
|
12
|
+
The value is the power of the WHOLE machine (screen included), so benchmarks
|
|
13
|
+
report it next to an idle baseline.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
import subprocess
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _read() -> tuple[float, bool] | None:
|
|
24
|
+
"""Returns (watts drawn, on_battery) or None when no gauge is present."""
|
|
25
|
+
try:
|
|
26
|
+
out = subprocess.run(["ioreg", "-rn", "AppleSmartBattery"], capture_output=True,
|
|
27
|
+
text=True, timeout=5).stdout
|
|
28
|
+
except Exception:
|
|
29
|
+
return None
|
|
30
|
+
amp = re.search(r'^\s*"InstantAmperage" = (\d+)', out, re.M)
|
|
31
|
+
volt = re.search(r'^\s*"Voltage" = (\d+)', out, re.M)
|
|
32
|
+
ext = re.search(r'^\s*"ExternalConnected" = (\w+)', out, re.M)
|
|
33
|
+
if not (amp and volt and ext):
|
|
34
|
+
return None
|
|
35
|
+
ma = int(amp.group(1))
|
|
36
|
+
if ma >= 1 << 63: # gauge exposes a signed value as u64
|
|
37
|
+
ma -= 1 << 64
|
|
38
|
+
watts = abs(ma) / 1000.0 * int(volt.group(1)) / 1000.0
|
|
39
|
+
return watts, ext.group(1) == "No"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def available() -> bool:
|
|
43
|
+
r = _read()
|
|
44
|
+
return bool(r and r[1])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PowerSampler:
|
|
48
|
+
"""Context manager: integrates battery power over the block (trapezoid)."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, period_s: float = 0.5):
|
|
51
|
+
self.period = period_s
|
|
52
|
+
self.samples: list[tuple[float, float]] = []
|
|
53
|
+
self._stop = threading.Event()
|
|
54
|
+
self._t: threading.Thread | None = None
|
|
55
|
+
self.valid = False
|
|
56
|
+
|
|
57
|
+
def _loop(self):
|
|
58
|
+
while not self._stop.is_set():
|
|
59
|
+
r = _read()
|
|
60
|
+
if r is None or not r[1]:
|
|
61
|
+
self.valid = False
|
|
62
|
+
return
|
|
63
|
+
self.samples.append((time.perf_counter(), r[0]))
|
|
64
|
+
self._stop.wait(self.period)
|
|
65
|
+
|
|
66
|
+
def __enter__(self):
|
|
67
|
+
self.valid = available()
|
|
68
|
+
if self.valid:
|
|
69
|
+
self._t = threading.Thread(target=self._loop, daemon=True)
|
|
70
|
+
self._t.start()
|
|
71
|
+
self.t0 = time.perf_counter()
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def __exit__(self, *exc):
|
|
75
|
+
self.t1 = time.perf_counter()
|
|
76
|
+
self._stop.set()
|
|
77
|
+
if self._t:
|
|
78
|
+
self._t.join(timeout=6)
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def mean_watts(self) -> float | None:
|
|
83
|
+
if not self.valid or not self.samples:
|
|
84
|
+
return None
|
|
85
|
+
return sum(w for _, w in self.samples) / len(self.samples)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def static(self) -> bool:
|
|
89
|
+
"""True when the gauge never changed during the window (reading is stale/coarse)."""
|
|
90
|
+
return len({w for _, w in self.samples}) <= 1
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def joules(self) -> float | None:
|
|
94
|
+
mw = self.mean_watts
|
|
95
|
+
return None if mw is None else mw * (self.t1 - self.t0)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def idle_watts(seconds: float = 4.0) -> float | None:
|
|
99
|
+
with PowerSampler(0.5) as p:
|
|
100
|
+
time.sleep(seconds)
|
|
101
|
+
return p.mean_watts
|
spatial/core/ledger.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Append-only, hash-chained JSONL ledger of measurements and decisions (the R&D trace).
|
|
2
|
+
|
|
3
|
+
Every row carries `prev` (hash of the previous row) and `hash` = SHA-256(prev | canonical
|
|
4
|
+
JSON of the row). `verify()` detects an edited, removed or reordered row. This is tamper
|
|
5
|
+
EVIDENCE, not a signature: someone who can rewrite the whole file can rebuild the chain.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .model import Measurement
|
|
16
|
+
|
|
17
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
18
|
+
RESULTS = ROOT / "benchmarks" / "results"
|
|
19
|
+
GENESIS = "0" * 64
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _digest(prev: str, row: dict) -> str:
|
|
23
|
+
body = json.dumps(row, sort_keys=True, separators=(",", ":"), default=str)
|
|
24
|
+
return hashlib.sha256((prev + body).encode()).hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Ledger:
|
|
28
|
+
def __init__(self, path: str | os.PathLike | None = None):
|
|
29
|
+
self.path = Path(path) if path else RESULTS / "ledger.jsonl"
|
|
30
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
self._last = self._tail_hash()
|
|
32
|
+
|
|
33
|
+
def _tail_hash(self) -> str:
|
|
34
|
+
last = GENESIS
|
|
35
|
+
if self.path.exists():
|
|
36
|
+
for line in self.path.read_text().splitlines():
|
|
37
|
+
if line.strip():
|
|
38
|
+
last = json.loads(line).get("hash", last)
|
|
39
|
+
return last
|
|
40
|
+
|
|
41
|
+
def _append(self, row: dict) -> None:
|
|
42
|
+
row = json.loads(json.dumps(row, default=str)) # normalise before hashing
|
|
43
|
+
h = _digest(self._last, row)
|
|
44
|
+
with open(self.path, "a") as f:
|
|
45
|
+
f.write(json.dumps({**row, "prev": self._last, "hash": h}) + "\n")
|
|
46
|
+
self._last = h
|
|
47
|
+
|
|
48
|
+
def record(self, m: Measurement) -> Measurement:
|
|
49
|
+
self._append({"type": "measurement", **m.to_dict()})
|
|
50
|
+
return m
|
|
51
|
+
|
|
52
|
+
def decide(self, engine: str, decision: str, reason: str, **data) -> None:
|
|
53
|
+
self._append({"type": "decision", "ts": time.time(), "engine": engine,
|
|
54
|
+
"decision": decision, "reason": reason, **data})
|
|
55
|
+
|
|
56
|
+
def rows(self, type_: str | None = None) -> list[dict]:
|
|
57
|
+
if not self.path.exists():
|
|
58
|
+
return []
|
|
59
|
+
out = []
|
|
60
|
+
for line in self.path.read_text().splitlines():
|
|
61
|
+
if line.strip():
|
|
62
|
+
r = json.loads(line)
|
|
63
|
+
if type_ is None or r.get("type") == type_:
|
|
64
|
+
out.append(r)
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
def verify(self) -> tuple[bool, int | None]:
|
|
68
|
+
"""(True, None) when the chain is intact, else (False, index of the first bad row).
|
|
69
|
+
Rows written before chaining existed (no `hash`) are reported as bad."""
|
|
70
|
+
prev = GENESIS
|
|
71
|
+
for i, r in enumerate(self.rows()):
|
|
72
|
+
h, p = r.pop("hash", None), r.pop("prev", None)
|
|
73
|
+
if h is None or p != prev or _digest(prev, r) != h:
|
|
74
|
+
return False, i
|
|
75
|
+
prev = h
|
|
76
|
+
return True, None
|