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
spatial/__init__.py
ADDED
spatial/ai/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Spatial AI Engine: first components in the public repository.
|
|
2
|
+
|
|
3
|
+
kvcache.py: a toy NumPy transformer with a real key/value cache, used to measure recompute
|
|
4
|
+
versus restore through the tiered memory store (CPU, toy sizes: says nothing about GPUs).
|
|
5
|
+
|
|
6
|
+
The model-placement planner (layers over heterogeneous nodes, memory, network, availability)
|
|
7
|
+
lives in the private `spatial_pro` package. When that package is installed, `plan_pipeline`
|
|
8
|
+
and friends are re-exported here; otherwise they are None and nothing pretends to work.
|
|
9
|
+
No real language model is executed by this repository.
|
|
10
|
+
"""
|
|
11
|
+
try: # pragma: no cover - depends on a private package
|
|
12
|
+
from spatial_pro.placement import ModelSpec, NodeSpec, plan_pipeline # noqa: F401
|
|
13
|
+
HAVE_PLANNER = True
|
|
14
|
+
except ImportError:
|
|
15
|
+
ModelSpec = NodeSpec = plan_pipeline = None
|
|
16
|
+
HAVE_PLANNER = False
|
|
17
|
+
|
|
18
|
+
from .kvcache import ToyTransformer, measure_restore_vs_recompute # noqa: E402,F401
|
spatial/ai/kvcache.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Toy causal transformer with a key/value cache, to MEASURE recompute versus restore.
|
|
2
|
+
|
|
3
|
+
A small random-weight decoder (NumPy, CPU). It is not a language model and its speed says
|
|
4
|
+
nothing about GPUs. It exists so the KV-cache mechanics are real and testable:
|
|
5
|
+
* prefill(tokens) builds the per-layer K/V tensors;
|
|
6
|
+
* continuing from a restored cache gives exactly the same logits as recomputing;
|
|
7
|
+
* the cache can be parked in a TieredStore, so restore time and bytes are measured.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ToyTransformer:
|
|
17
|
+
def __init__(self, layers: int = 4, d: int = 64, heads: int = 4, vocab: int = 256, seed: int = 0):
|
|
18
|
+
rng = np.random.default_rng(seed)
|
|
19
|
+
self.L, self.d, self.h, self.vocab = layers, d, heads, vocab
|
|
20
|
+
g = lambda *s: (rng.standard_normal(s) / np.sqrt(s[0])).astype(np.float32)
|
|
21
|
+
self.emb = g(vocab, d)
|
|
22
|
+
self.W = [{k: g(d, d) for k in ("q", "k", "v", "o", "m1", "m2")} for _ in range(layers)]
|
|
23
|
+
self.out = g(d, vocab)
|
|
24
|
+
|
|
25
|
+
def _pos(self, i0: int, n: int) -> np.ndarray:
|
|
26
|
+
pos = np.arange(i0, i0 + n)[:, None] / (10000 ** (np.arange(self.d)[None, :] / self.d))
|
|
27
|
+
return np.sin(pos).astype(np.float32)
|
|
28
|
+
|
|
29
|
+
def forward(self, tokens, cache: list | None = None):
|
|
30
|
+
"""Process `tokens` after the positions already in `cache`. Returns (logits of the last
|
|
31
|
+
token, new cache). cache = [(K, V)] per layer, K and V of shape [positions, d]."""
|
|
32
|
+
n0 = 0 if cache is None else cache[0][0].shape[0]
|
|
33
|
+
x = self.emb[np.asarray(tokens)] + self._pos(n0, len(tokens))
|
|
34
|
+
new, dh = [], self.d // self.h
|
|
35
|
+
for li, w in enumerate(self.W):
|
|
36
|
+
k_new, v_new = x @ w["k"], x @ w["v"]
|
|
37
|
+
K = k_new if cache is None else np.concatenate([cache[li][0], k_new])
|
|
38
|
+
V = v_new if cache is None else np.concatenate([cache[li][1], v_new])
|
|
39
|
+
q = (x @ w["q"]).reshape(len(tokens), self.h, dh).transpose(1, 0, 2)
|
|
40
|
+
Kh = K.reshape(-1, self.h, dh).transpose(1, 0, 2)
|
|
41
|
+
Vh = V.reshape(-1, self.h, dh).transpose(1, 0, 2)
|
|
42
|
+
att = q @ Kh.transpose(0, 2, 1) / np.sqrt(dh)
|
|
43
|
+
mask = np.arange(K.shape[0])[None, :] > (n0 + np.arange(len(tokens)))[:, None] # causal
|
|
44
|
+
att = np.where(mask[None], np.float32(-1e30), att)
|
|
45
|
+
att = np.exp(att - att.max(-1, keepdims=True))
|
|
46
|
+
att /= att.sum(-1, keepdims=True)
|
|
47
|
+
x = x + (att @ Vh).transpose(1, 0, 2).reshape(len(tokens), self.d) @ w["o"]
|
|
48
|
+
x = x + np.maximum(x @ w["m1"], 0) @ w["m2"]
|
|
49
|
+
x = x.astype(np.float32) # keep the cache in one dtype (float32)
|
|
50
|
+
new.append((K, V))
|
|
51
|
+
return x[-1] @ self.out, new
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cache_to_pages(cache: list) -> list[bytes]:
|
|
55
|
+
return [a.tobytes() for kv in cache for a in kv]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cache_from_pages(pages: list[bytes], d: int) -> list:
|
|
59
|
+
arrs = [np.frombuffer(p, dtype=np.float32).reshape(-1, d) for p in pages]
|
|
60
|
+
return [(arrs[i], arrs[i + 1]) for i in range(0, len(arrs), 2)]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def measure_restore_vs_recompute(model: ToyTransformer, prefix, store, repeat: int = 3) -> dict:
|
|
64
|
+
"""Times, on this machine: recomputing the prefix cache versus restoring it from `store`."""
|
|
65
|
+
t0 = time.perf_counter()
|
|
66
|
+
_, cache = model.forward(prefix)
|
|
67
|
+
recompute_s = time.perf_counter() - t0
|
|
68
|
+
for _ in range(repeat - 1):
|
|
69
|
+
t0 = time.perf_counter()
|
|
70
|
+
model.forward(prefix)
|
|
71
|
+
recompute_s = min(recompute_s, time.perf_counter() - t0)
|
|
72
|
+
pages = cache_to_pages(cache)
|
|
73
|
+
for i, p in enumerate(pages):
|
|
74
|
+
store.put(i, p)
|
|
75
|
+
restore_s = float("inf")
|
|
76
|
+
for _ in range(repeat):
|
|
77
|
+
t0 = time.perf_counter()
|
|
78
|
+
restored = cache_from_pages([store.get(i) for i in range(len(pages))], model.d)
|
|
79
|
+
restore_s = min(restore_s, time.perf_counter() - t0)
|
|
80
|
+
return {"prefix_tokens": len(prefix), "cache_bytes": sum(map(len, pages)), "recompute_s": recompute_s,
|
|
81
|
+
"restore_s": restore_s, "restore_faster": restore_s < recompute_s, "cache": cache, "restored": restored}
|
spatial/cli.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""`spatial` command line: one entry point for the three operational engines."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
11
|
+
BENCHES = {"cpu": "bench_cpu", "memory": "bench_memory", "gpu": "bench_gpu",
|
|
12
|
+
"runtime": "bench_runtime", "q": "bench_q"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def cmd_audit(a):
|
|
16
|
+
from .compute import detect_node, limitations, snapshot
|
|
17
|
+
from .compute.monitor import underused
|
|
18
|
+
node, snap = detect_node(), snapshot()
|
|
19
|
+
if a.json:
|
|
20
|
+
print(json.dumps({"node": node.to_dict(), "snapshot": snap, "limitations": limitations(node)},
|
|
21
|
+
indent=1, default=str))
|
|
22
|
+
return 0
|
|
23
|
+
print(f"{node.attrs.get('model')} | {node.attrs.get('cpu_brand')} | {node.os} | {node.arch}")
|
|
24
|
+
for r in node.resources:
|
|
25
|
+
cap = f"{r.capacity / 2**30:.1f} GiB" if r.unit == "bytes" else f"{r.capacity:g} {r.unit}"
|
|
26
|
+
if "utilization_percent" in r.attrs:
|
|
27
|
+
cap += f" ({r.attrs['utilization_percent']:.0f}% busy)"
|
|
28
|
+
print(f" {r.kind.value:<11} {r.name:<42} {cap}")
|
|
29
|
+
print(f"memory pressure: {snap.get('memory_pressure')} | compressor ratio: {snap.get('compressor_ratio')} "
|
|
30
|
+
f"| swap used: {snap['swap_used'] / 2**30:.2f} GiB")
|
|
31
|
+
for u in underused(snap):
|
|
32
|
+
print(" idle:", u)
|
|
33
|
+
print("limitations:")
|
|
34
|
+
for x in limitations(node):
|
|
35
|
+
print(" -", x)
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_status(a):
|
|
40
|
+
from .core.registry import ENGINES
|
|
41
|
+
for e in ENGINES:
|
|
42
|
+
print(f"{e.name:<42} {e.status.value:<24} not done: {e.not_done}")
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cmd_plan(a):
|
|
47
|
+
from .runtime import inspect, plan
|
|
48
|
+
art = inspect(a.file)
|
|
49
|
+
print(f"{a.file}: {art.format} / {art.os} / {','.join(art.arch)} {art.detail}")
|
|
50
|
+
for r in plan(art):
|
|
51
|
+
print(f" [{'x' if r.feasible else ' '}] {r.name:<16} {r.reason}")
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cmd_run(a):
|
|
56
|
+
from .runtime import execute
|
|
57
|
+
e = execute(a.file, a.args, route=a.route, timeout_s=a.timeout)
|
|
58
|
+
if e.stdout:
|
|
59
|
+
print(e.stdout)
|
|
60
|
+
print(f"[spatial] route={e.route} ok={e.ok} wall={e.wall_s:.2f}s "
|
|
61
|
+
+ (f"reason: {e.extra.get('not_runnable')}" if not e.ok and e.extra.get("not_runnable") else ""),
|
|
62
|
+
file=sys.stderr)
|
|
63
|
+
return 0 if e.ok else 1
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_q(a):
|
|
67
|
+
from .q import runtime
|
|
68
|
+
sys.argv = ["spatialq", *a.rest]
|
|
69
|
+
runtime.main()
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cmd_bench(a):
|
|
74
|
+
names = list(BENCHES) if a.name == "all" else [a.name]
|
|
75
|
+
bdir = ROOT / "benchmarks"
|
|
76
|
+
if not bdir.exists():
|
|
77
|
+
print("benchmarks/ not found: run from a source checkout", file=sys.stderr)
|
|
78
|
+
return 2
|
|
79
|
+
rc = 0
|
|
80
|
+
for n in names:
|
|
81
|
+
rc |= subprocess.run([sys.executable, f"{BENCHES[n]}.py"], cwd=bdir).returncode
|
|
82
|
+
rc |= subprocess.run([sys.executable, "report.py"], cwd=bdir).returncode
|
|
83
|
+
return rc
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_worker(a):
|
|
87
|
+
mod = {"compute": "spatial.compute.worker", "memory": "spatial.memory.pageserver",
|
|
88
|
+
"runtime": "spatial.runtime.runner"}[a.kind]
|
|
89
|
+
cmd = [sys.executable, "-m", mod, "--token-file", a.token_file, "--host", a.host, "--port", str(a.port)]
|
|
90
|
+
if a.allow_remote:
|
|
91
|
+
cmd.append("--allow-remote")
|
|
92
|
+
return subprocess.run(cmd).returncode
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main(argv=None):
|
|
96
|
+
ap = argparse.ArgumentParser(prog="spatial", description="SPATIAL COMPUTING — measure, decide, prove")
|
|
97
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
98
|
+
p = sub.add_parser("audit", help="detect hardware, usage and limits of this machine")
|
|
99
|
+
p.add_argument("--json", action="store_true")
|
|
100
|
+
p.set_defaults(fn=cmd_audit)
|
|
101
|
+
sub.add_parser("status", help="honest status of every engine").set_defaults(fn=cmd_status)
|
|
102
|
+
p = sub.add_parser("plan", help="inspect a binary and list execution routes")
|
|
103
|
+
p.add_argument("file")
|
|
104
|
+
p.set_defaults(fn=cmd_plan)
|
|
105
|
+
p = sub.add_parser("run", help="run a binary through the best feasible route")
|
|
106
|
+
p.add_argument("file")
|
|
107
|
+
p.add_argument("args", nargs="*")
|
|
108
|
+
p.add_argument("--route")
|
|
109
|
+
p.add_argument("--timeout", type=float, default=300)
|
|
110
|
+
p.set_defaults(fn=cmd_run)
|
|
111
|
+
p = sub.add_parser("q", help="SPATIAL Q: run|check|qasm|ir <file.sq>")
|
|
112
|
+
p.add_argument("rest", nargs=argparse.REMAINDER)
|
|
113
|
+
p.set_defaults(fn=cmd_q)
|
|
114
|
+
p = sub.add_parser("bench", help="run benchmarks and regenerate docs/RESULTATS.md")
|
|
115
|
+
p.add_argument("name", choices=[*BENCHES, "all"])
|
|
116
|
+
p.set_defaults(fn=cmd_bench)
|
|
117
|
+
p = sub.add_parser("serve", help="start a compute worker, a page server or a sandboxed binary runner (loopback by default)")
|
|
118
|
+
p.add_argument("kind", choices=["compute", "memory", "runtime"])
|
|
119
|
+
p.add_argument("--token-file", required=True)
|
|
120
|
+
p.add_argument("--host", default="127.0.0.1")
|
|
121
|
+
p.add_argument("--port", type=int, default=0)
|
|
122
|
+
p.add_argument("--allow-remote", action="store_true", help="explicit consent to accept LAN connections")
|
|
123
|
+
p.set_defaults(fn=cmd_worker)
|
|
124
|
+
a = ap.parse_args(argv)
|
|
125
|
+
try:
|
|
126
|
+
return a.fn(a)
|
|
127
|
+
except (Exception, SystemExit) as e: # user-facing tool: one clean line, no traceback
|
|
128
|
+
if isinstance(e, SystemExit) and not isinstance(e.code, str):
|
|
129
|
+
raise
|
|
130
|
+
print(f"spatial: {type(e).__name__}: {e}", file=sys.stderr)
|
|
131
|
+
return 1
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
sys.exit(main())
|
spatial/compute/cost.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Cost model. Every price is an ASSUMPTION with a stated basis; override via CostModel(...).
|
|
2
|
+
|
|
3
|
+
Local cost is derived from MEASURED time/energy. Remote/cloud cost is an ESTIMATE:
|
|
4
|
+
no cloud machine was rented for these experiments, so cloud rows are labelled
|
|
5
|
+
`estimated=True` and assume the measured local runtime scaled by `speed_ratio`.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class CloudOffer:
|
|
14
|
+
name: str
|
|
15
|
+
usd_per_hour: float
|
|
16
|
+
vcpus: int
|
|
17
|
+
gpu: str | None
|
|
18
|
+
basis: str # where the number comes from; to be re-checked before any business use
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# List prices recalled from public price pages (us-east-1 on-demand). NOT re-verified today.
|
|
22
|
+
OFFERS = {
|
|
23
|
+
"aws_c7i_large": CloudOffer("AWS c7i.large", 0.0893, 2, None, "AWS on-demand list price, unverified"),
|
|
24
|
+
"aws_c7i_4xlarge": CloudOffer("AWS c7i.4xlarge", 0.714, 16, None, "AWS on-demand list price, unverified"),
|
|
25
|
+
"aws_g4dn_xlarge": CloudOffer("AWS g4dn.xlarge (T4)", 0.526, 4, "T4", "AWS on-demand list price, unverified"),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class CostModel:
|
|
31
|
+
usd_per_kwh: float = 0.17 # assumption: ~US residential average
|
|
32
|
+
laptop_price_usd: float = 0.0 # sunk by default; set to amortise hardware
|
|
33
|
+
laptop_life_hours: float = 4 * 365 * 8 # 4 years x 8 h/day
|
|
34
|
+
egress_usd_per_gb: float = 0.09 # assumption: typical hyperscaler internet egress
|
|
35
|
+
min_billing_s: float = 60.0 # assumption: per-second billing, 60 s minimum
|
|
36
|
+
offers: dict = field(default_factory=lambda: dict(OFFERS))
|
|
37
|
+
|
|
38
|
+
def local(self, wall_s: float, energy_j: float | None, idle_watts: float | None = None) -> dict:
|
|
39
|
+
"""Cost of a measured local run. Energy cost uses the marginal energy above idle
|
|
40
|
+
when an idle baseline is known, otherwise the whole-machine energy."""
|
|
41
|
+
out = {"estimated": False, "hardware_usd": 0.0, "energy_usd": None, "total_usd": None}
|
|
42
|
+
if self.laptop_price_usd:
|
|
43
|
+
out["hardware_usd"] = self.laptop_price_usd / self.laptop_life_hours * wall_s / 3600
|
|
44
|
+
if energy_j is not None:
|
|
45
|
+
j = energy_j - (idle_watts * wall_s if idle_watts else 0.0)
|
|
46
|
+
out["marginal_energy_j"] = max(j, 0.0)
|
|
47
|
+
out["energy_usd"] = max(j, 0.0) / 3.6e6 * self.usd_per_kwh
|
|
48
|
+
out["total_usd"] = out["hardware_usd"] + out["energy_usd"]
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
def cloud(self, offer_key: str, local_wall_s: float, speed_ratio: float,
|
|
52
|
+
payload_bytes: int = 0, result_bytes: int = 0, startup_s: float = 0.0) -> dict:
|
|
53
|
+
"""ESTIMATE of running the same job on a cloud offer.
|
|
54
|
+
speed_ratio = assumed (cloud throughput / local throughput)."""
|
|
55
|
+
o = self.offers[offer_key]
|
|
56
|
+
run_s = local_wall_s / speed_ratio + startup_s
|
|
57
|
+
billed = max(run_s, self.min_billing_s)
|
|
58
|
+
compute = o.usd_per_hour * billed / 3600
|
|
59
|
+
net = result_bytes / 1e9 * self.egress_usd_per_gb # ingress assumed free
|
|
60
|
+
return {"estimated": True, "offer": o.name, "basis": o.basis, "assumed_speed_ratio": speed_ratio,
|
|
61
|
+
"run_s": run_s, "billed_s": billed, "compute_usd": compute, "network_usd": net,
|
|
62
|
+
"total_usd": compute + net}
|
|
63
|
+
|
|
64
|
+
def break_even_seconds(self, offer_key: str, local_watts_marginal: float) -> float | None:
|
|
65
|
+
"""Local run time above which cloud min-billing stops dominating (same speed)."""
|
|
66
|
+
o = self.offers[offer_key]
|
|
67
|
+
local_per_s = local_watts_marginal / 3.6e6 * self.usd_per_kwh
|
|
68
|
+
cloud_per_s = o.usd_per_hour / 3600
|
|
69
|
+
return None if cloud_per_s <= local_per_s else self.min_billing_s
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Hardware detection (macOS first; degrades gracefully elsewhere). Nothing is assumed."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import platform
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import uuid
|
|
9
|
+
|
|
10
|
+
import psutil
|
|
11
|
+
|
|
12
|
+
from ..core.model import Node, Resource, ResourceKind
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _sysctl(key: str) -> str | None:
|
|
16
|
+
try:
|
|
17
|
+
r = subprocess.run(["sysctl", "-n", key], capture_output=True, text=True, timeout=5)
|
|
18
|
+
return r.stdout.strip() or None
|
|
19
|
+
except Exception:
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _os_name() -> str:
|
|
24
|
+
if platform.system() == "Darwin":
|
|
25
|
+
try: # platform.mac_ver() can report a compatibility version (10.16)
|
|
26
|
+
v = subprocess.run(["sw_vers", "-productVersion"], capture_output=True, text=True, timeout=5).stdout.strip()
|
|
27
|
+
return f"macOS {v}"
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
return f"{platform.system()} {platform.release()}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _gpus() -> list[dict]:
|
|
34
|
+
try:
|
|
35
|
+
out = subprocess.run(["system_profiler", "SPDisplaysDataType", "-json"],
|
|
36
|
+
capture_output=True, text=True, timeout=30).stdout
|
|
37
|
+
return json.loads(out).get("SPDisplaysDataType", [])
|
|
38
|
+
except Exception:
|
|
39
|
+
return []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_nvidia_smi(csv: str) -> list[dict]:
|
|
43
|
+
"""Parse `nvidia-smi --query-gpu=name,memory.total,memory.used,utilization.gpu --format=csv,noheader,nounits`."""
|
|
44
|
+
out = []
|
|
45
|
+
for line in csv.strip().splitlines():
|
|
46
|
+
parts = [p.strip() for p in line.split(",")]
|
|
47
|
+
if len(parts) != 4:
|
|
48
|
+
continue
|
|
49
|
+
name, total, used, util = parts
|
|
50
|
+
try:
|
|
51
|
+
out.append({"name": name, "vram_bytes": int(float(total)) * 2**20,
|
|
52
|
+
"vram_used_bytes": int(float(used)) * 2**20, "utilization_percent": float(util)})
|
|
53
|
+
except ValueError:
|
|
54
|
+
continue
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _nvidia_gpus() -> list[dict]:
|
|
59
|
+
exe = shutil.which("nvidia-smi")
|
|
60
|
+
if not exe:
|
|
61
|
+
return []
|
|
62
|
+
try:
|
|
63
|
+
r = subprocess.run([exe, "--query-gpu=name,memory.total,memory.used,utilization.gpu",
|
|
64
|
+
"--format=csv,noheader,nounits"], capture_output=True, text=True, timeout=20)
|
|
65
|
+
return parse_nvidia_smi(r.stdout) if r.returncode == 0 else []
|
|
66
|
+
except Exception:
|
|
67
|
+
return []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def virtualization() -> dict:
|
|
71
|
+
"""What can actually run guests here."""
|
|
72
|
+
info = {"hvf": _sysctl("kern.hv_support") == "1"}
|
|
73
|
+
for tool in ("qemu-system-x86_64", "qemu-system-aarch64", "docker", "podman", "limactl",
|
|
74
|
+
"wine", "wine64", "node", "wasmtime"):
|
|
75
|
+
info[tool] = shutil.which(tool)
|
|
76
|
+
return info
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def detect_node() -> Node:
|
|
80
|
+
vm = psutil.virtual_memory()
|
|
81
|
+
arch = platform.machine()
|
|
82
|
+
node = Node(node_id=uuid.uuid5(uuid.NAMESPACE_DNS, platform.node()).hex[:12],
|
|
83
|
+
hostname="local", os=_os_name(),
|
|
84
|
+
arch=arch)
|
|
85
|
+
node.attrs["model"] = _sysctl("hw.model")
|
|
86
|
+
node.attrs["cpu_brand"] = _sysctl("machdep.cpu.brand_string")
|
|
87
|
+
node.attrs["virtualization"] = virtualization()
|
|
88
|
+
feats = (_sysctl("machdep.cpu.features") or "") + " " + (_sysctl("machdep.cpu.leaf7_features") or "")
|
|
89
|
+
simd = [f for f in ("SSE4.2", "AVX1.0", "AVX2", "AVX512F", "FMA") if f in feats.split()]
|
|
90
|
+
node.resources.append(Resource(
|
|
91
|
+
ResourceKind.CPU, node.attrs["cpu_brand"] or "cpu",
|
|
92
|
+
capacity=psutil.cpu_count(logical=False) or 1, unit="physical cores",
|
|
93
|
+
attrs={"logical": psutil.cpu_count(logical=True), "simd": simd,
|
|
94
|
+
"l2_bytes": int(_sysctl("hw.l2cachesize") or 0),
|
|
95
|
+
"l3_bytes": int(_sysctl("hw.l3cachesize") or 0)}))
|
|
96
|
+
node.resources.append(Resource(ResourceKind.RAM, "physical RAM", capacity=vm.total,
|
|
97
|
+
unit="bytes", available=vm.available))
|
|
98
|
+
sw = psutil.swap_memory()
|
|
99
|
+
node.resources.append(Resource(
|
|
100
|
+
ResourceKind.FAR_MEMORY, "OS swap (SSD-backed, NOT RAM)", capacity=sw.total, unit="bytes",
|
|
101
|
+
available=sw.free, attrs={"compressor_mode": _sysctl("vm.compressor_mode")}))
|
|
102
|
+
for g in _gpus():
|
|
103
|
+
node.resources.append(Resource(
|
|
104
|
+
ResourceKind.GPU, g.get("sppci_model", "gpu"), capacity=1, unit="device",
|
|
105
|
+
attrs={"vram": g.get("spdisplays_vram_shared") or g.get("spdisplays_vram") or g.get("_spdisplays_vram"),
|
|
106
|
+
"metal": g.get("spdisplays_mtlgpufamilysupport"),
|
|
107
|
+
"vendor": g.get("spdisplays_vendor"), "integrated": "builtin" in str(g.get("sppci_bus", "")).lower().replace("-", "")}))
|
|
108
|
+
for g in _nvidia_gpus():
|
|
109
|
+
node.resources.append(Resource(
|
|
110
|
+
ResourceKind.GPU, g["name"], capacity=g["vram_bytes"], unit="bytes",
|
|
111
|
+
available=g["vram_bytes"] - g["vram_used_bytes"],
|
|
112
|
+
attrs={"vendor": "NVIDIA", "cuda": True, "integrated": False,
|
|
113
|
+
"utilization_percent": g["utilization_percent"]}))
|
|
114
|
+
du = psutil.disk_usage("/System/Volumes/Data" if platform.system() == "Darwin" else "/")
|
|
115
|
+
node.resources.append(Resource(ResourceKind.STORAGE, "system volume", capacity=du.total,
|
|
116
|
+
unit="bytes", available=du.free))
|
|
117
|
+
return node
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def limitations(node: Node) -> list[str]:
|
|
121
|
+
"""Hard limits derived from what was detected (used by the scheduler and the audit)."""
|
|
122
|
+
out = []
|
|
123
|
+
cpu = node.by_kind(ResourceKind.CPU)[0]
|
|
124
|
+
if cpu.capacity <= 2:
|
|
125
|
+
out.append(f"Only {int(cpu.capacity)} physical cores: parallel speed-up is bounded near 2x "
|
|
126
|
+
"(hyper-threads add little for compute-bound code).")
|
|
127
|
+
if "AVX512F" not in cpu.attrs.get("simd", []):
|
|
128
|
+
out.append("No AVX-512; SIMD width limited to AVX2 (256-bit).")
|
|
129
|
+
if node.arch == "x86_64":
|
|
130
|
+
out.append("Intel Mac: no Apple Neural Engine/NPU, no MLX, no Apple Virtualization.framework "
|
|
131
|
+
"Rosetta-for-Linux; ARM guests run under slow TCG emulation only.")
|
|
132
|
+
for g in node.by_kind(ResourceKind.GPU):
|
|
133
|
+
if g.attrs.get("integrated"):
|
|
134
|
+
out.append(f"GPU {g.name} is integrated: shares system RAM, no CUDA, low compute throughput.")
|
|
135
|
+
if not node.attrs["virtualization"].get("wine") and not node.attrs["virtualization"].get("wine64"):
|
|
136
|
+
out.append("No Wine installed: Windows PE binaries cannot be executed locally.")
|
|
137
|
+
if not node.attrs["virtualization"].get("docker"):
|
|
138
|
+
out.append("No Docker/Podman: OCI containers unavailable; QEMU micro-VM is the Linux path.")
|
|
139
|
+
return out
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Resource monitoring: CPU, RAM, macOS compressor and swap, memory pressure."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import resource
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import psutil
|
|
12
|
+
|
|
13
|
+
from ..core import energy
|
|
14
|
+
from ..core.model import Measurement
|
|
15
|
+
|
|
16
|
+
_PRESSURE = {1: "normal", 2: "warning", 4: "critical"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def vm_stat() -> dict[str, int]:
|
|
20
|
+
"""Parsed `vm_stat` in bytes (macOS). Empty dict elsewhere."""
|
|
21
|
+
try:
|
|
22
|
+
out = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=5).stdout
|
|
23
|
+
except Exception:
|
|
24
|
+
return {}
|
|
25
|
+
page = int(re.search(r"page size of (\d+)", out).group(1))
|
|
26
|
+
d = {}
|
|
27
|
+
for k, v in re.findall(r'^"?([^":]+)"?:\s+(\d+)\.', out, re.M):
|
|
28
|
+
d[k.strip().lower().replace(" ", "_").replace("-", "_")] = int(v)
|
|
29
|
+
for k in list(d):
|
|
30
|
+
if k.startswith("pages_"):
|
|
31
|
+
d[k + "_bytes"] = d[k] * page
|
|
32
|
+
d["page_size"] = page
|
|
33
|
+
return d
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def snapshot() -> dict:
|
|
37
|
+
vm = psutil.virtual_memory()
|
|
38
|
+
sw = psutil.swap_memory()
|
|
39
|
+
vs = vm_stat()
|
|
40
|
+
snap = {
|
|
41
|
+
"ts": time.time(),
|
|
42
|
+
"cpu_percent": psutil.cpu_percent(interval=0.3),
|
|
43
|
+
"load1": os.getloadavg()[0],
|
|
44
|
+
"ram_total": vm.total, "ram_available": vm.available, "ram_used_percent": vm.percent,
|
|
45
|
+
"swap_used": sw.used, "swap_total": sw.total,
|
|
46
|
+
}
|
|
47
|
+
if vs:
|
|
48
|
+
stored = vs.get("pages_stored_in_compressor_bytes", 0)
|
|
49
|
+
occupied = vs.get("pages_occupied_by_compressor_bytes", 0)
|
|
50
|
+
snap.update({
|
|
51
|
+
"compressor_logical_bytes": stored, # uncompressed size held by compressor
|
|
52
|
+
"compressor_physical_bytes": occupied, # RAM actually used to hold it
|
|
53
|
+
"compressor_ratio": round(stored / occupied, 2) if occupied else None,
|
|
54
|
+
"compressor_saved_bytes": stored - occupied,
|
|
55
|
+
"swapins": vs.get("swapins"), "swapouts": vs.get("swapouts"),
|
|
56
|
+
})
|
|
57
|
+
try:
|
|
58
|
+
lvl = int(subprocess.run(["sysctl", "-n", "kern.memorystatus_vm_pressure_level"],
|
|
59
|
+
capture_output=True, text=True, timeout=5).stdout.strip())
|
|
60
|
+
snap["memory_pressure"] = _PRESSURE.get(lvl, str(lvl))
|
|
61
|
+
except Exception:
|
|
62
|
+
snap["memory_pressure"] = None
|
|
63
|
+
return snap
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def underused(snap: dict) -> list[str]:
|
|
67
|
+
"""Flag resources that are idle right now (input for the optimizer)."""
|
|
68
|
+
out = []
|
|
69
|
+
if snap["cpu_percent"] < 25:
|
|
70
|
+
out.append(f"CPU {snap['cpu_percent']:.0f}% busy: {100 - snap['cpu_percent']:.0f}% idle capacity")
|
|
71
|
+
if snap["ram_available"] / snap["ram_total"] > 0.4:
|
|
72
|
+
out.append(f"RAM {snap['ram_available'] / 2**30:.1f} GiB available of {snap['ram_total'] / 2**30:.0f}")
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _peak_rss() -> int:
|
|
77
|
+
r = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
78
|
+
return r if sys.platform == "darwin" else r * 1024 # bytes on macOS, KiB on Linux
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Measure:
|
|
82
|
+
"""Measure a block: wall, CPU (self + waited children), peak RSS, battery energy."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, engine: str, experiment: str, strategy: str, power: bool = True):
|
|
85
|
+
self.m = Measurement(engine, experiment, strategy, ok=False, wall_s=0.0)
|
|
86
|
+
self._power = energy.PowerSampler() if power else None
|
|
87
|
+
|
|
88
|
+
def __enter__(self) -> Measurement:
|
|
89
|
+
t = os.times()
|
|
90
|
+
self._cpu0 = t.user + t.system + t.children_user + t.children_system
|
|
91
|
+
if self._power:
|
|
92
|
+
self._power.__enter__()
|
|
93
|
+
self._t0 = time.perf_counter()
|
|
94
|
+
return self.m
|
|
95
|
+
|
|
96
|
+
def __exit__(self, et, ev, tb):
|
|
97
|
+
self.m.wall_s = time.perf_counter() - self._t0
|
|
98
|
+
if self._power:
|
|
99
|
+
self._power.__exit__(et, ev, tb)
|
|
100
|
+
self.m.energy_j = self._power.joules
|
|
101
|
+
if self._power.mean_watts is not None:
|
|
102
|
+
self.m.metrics["mean_watts_machine"] = round(self._power.mean_watts, 2)
|
|
103
|
+
self.m.metrics["power_gauge_static"] = self._power.static
|
|
104
|
+
t = os.times()
|
|
105
|
+
self.m.cpu_s = t.user + t.system + t.children_user + t.children_system - self._cpu0
|
|
106
|
+
self.m.peak_rss_bytes = _peak_rss()
|
|
107
|
+
if et is None:
|
|
108
|
+
self.m.ok = True
|
|
109
|
+
else:
|
|
110
|
+
self.m.notes = f"{et.__name__}: {ev}"
|
|
111
|
+
return True # failures are recorded, not raised
|