Code / experiments/micro/expH_capture_cost_frontier/implementation/benchmark.py

experiments/micro/expH_capture_cost_frontier/implementation/benchmark.py 292 lines
#!/usr/bin/env python3
# =============================================================================
#  Project   : modelmap
#  File      : experiments/micro/expH_capture_cost_frontier/implementation/benchmark.py
#  Purpose   : Run #1 — storage-format throughput on APFS + hook overhead MLX/MPS
#  Author    : Simon-Pierre Boucher
#  Contact   : contact@spboucher.ai
#  Website   : https://modelmap.io
#  Created   : 2026-08-12
#  Modified  : 2026-08-12
#  Platform  : macOS / Apple Silicon (arm64) — MLX / Metal / MPS
#  License   : All rights reserved (research code)
# =============================================================================
"""expH run #1. See hypothesis.md (registered before this run).

Part A — activation-store formats on APFS (warm cache, declared):
    raw np.memmap | safetensors (mmap) | zarr zstd | zarr uncompressed
    sequential write, sequential scan, random-batch reads (SAE-shuffle).
Part B — capture overhead on a synthetic 12-layer transformer (fp16):
    torch-MPS forward hooks vs MLX retained arrays;
    modes: plain / retain-on-device / retain + CPU copy + mmap write.
"""

from __future__ import annotations

import json
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path

import numpy as np

ROOT = Path(__file__).resolve().parents[4]
sys.path.insert(0, str(ROOT / "benchmarks"))
from hardware_manifest import manifest

REPEATS = 3
ROWS, DIM = 100_000, 4096          # ~0.82 GB fp16 per format
WRITE_CHUNK = 4_096                 # rows per write
BATCH = 4_096                       # rows per random-batch read
N_BATCHES = 24
SEED = 0

# Part B config
L, D_MODEL, N_HEADS, SEQ, BSZ = 12, 1024, 4, 512, 8
N_FWD, N_FWD_WRITE, WARMUP = 20, 10, 3


def _timed(fn, repeats=REPEATS):
    out = []
    for _ in range(repeats):
        t0 = time.perf_counter()
        fn()
        out.append(time.perf_counter() - t0)
    return out


# ---------------------------------------------------------------- Part A
def bench_storage(workdir: Path) -> list[dict]:
    rng = np.random.default_rng(SEED)
    data = rng.standard_normal((WRITE_CHUNK, DIM)).astype(np.float16)
    batches = [rng.integers(0, ROWS, BATCH) for _ in range(N_BATCHES)]
    total_bytes = ROWS * DIM * 2
    batch_bytes = BATCH * DIM * 2 * N_BATCHES
    results = []

    def record(fmt, op, times, nbytes):
        results.append({
            "format": fmt, "op": op, "bytes": nbytes,
            "seconds": times, "gb_per_s_mean": nbytes / 2**30 / np.mean(times),
        })
        print(f"  {fmt:18s} {op:12s} {nbytes/2**30/np.mean(times):8.2f} GB/s")

    # ---- raw np.memmap
    p = workdir / "acts.raw"
    def write_raw():
        m = np.memmap(p, dtype=np.float16, mode="w+", shape=(ROWS, DIM))
        for i in range(0, ROWS, WRITE_CHUNK):
            end = min(i + WRITE_CHUNK, ROWS)
            m[i:end] = data[: end - i]
        m.flush(); del m
    record("raw-mmap", "write", _timed(write_raw), total_bytes)
    m = np.memmap(p, dtype=np.float16, mode="r", shape=(ROWS, DIM))
    record("raw-mmap", "seq-scan", _timed(lambda m=m: float(np.asarray(m).sum(dtype=np.float32))), total_bytes)
    record("raw-mmap", "random-batch", _timed(lambda m=m: [m[b].sum(dtype=np.float32) for b in batches]), batch_bytes)
    del m

    # ---- safetensors (mmap-backed numpy)
    from safetensors import safe_open
    from safetensors.numpy import save_file
    p = workdir / "acts.safetensors"
    full = np.memmap(workdir / "acts.raw", dtype=np.float16, mode="r", shape=(ROWS, DIM))
    def write_st(full=full, p=p):
        save_file({"acts": np.asarray(full)}, str(p))
    record("safetensors", "write", _timed(write_st), total_bytes)
    def open_st():
        return safe_open(str(p), framework="np")
    f = open_st()
    t = f.get_tensor("acts")   # mmap-backed load
    record("safetensors", "seq-scan", _timed(lambda t=t: float(t.sum(dtype=np.float32))), total_bytes)
    record("safetensors", "random-batch", _timed(lambda t=t: [t[b].sum(dtype=np.float32) for b in batches]), batch_bytes)
    del t, f, full

    # ---- zarr (zstd default) and uncompressed
    import zarr
    for codec, name in ((None, "zarr-uncompressed"), ("default", "zarr-zstd")):
        p = workdir / f"acts_{name}.zarr"
        kwargs = {} if codec == "default" else {"compressors": None}
        def write_zarr(p=p, kwargs=kwargs):
            if p.exists():
                shutil.rmtree(p)
            z = zarr.create_array(store=str(p), shape=(ROWS, DIM), chunks=(WRITE_CHUNK, DIM),
                                  dtype=np.float16, **kwargs)
            for i in range(0, ROWS, WRITE_CHUNK):
                end = min(i + WRITE_CHUNK, ROWS)
                z[i:end] = data[: end - i]
        record(name, "write", _timed(write_zarr), total_bytes)
        z = zarr.open_array(store=str(p), mode="r")
        record(name, "seq-scan", _timed(lambda z=z: float(z[:].sum(dtype=np.float32))), total_bytes)
        record(name, "random-batch", _timed(lambda z=z: [z[np.sort(b)].sum(dtype=np.float32) for b in batches]), batch_bytes)

    return results


# ---------------------------------------------------------------- Part B
def bench_torch_mps(workdir: Path) -> list[dict]:
    import torch
    from torch import nn
    assert torch.backends.mps.is_available(), "MPS required"
    dev, dt = torch.device("mps"), torch.float16

    class Block(nn.Module):
        def __init__(self):
            super().__init__()
            self.ln1, self.ln2 = nn.LayerNorm(D_MODEL), nn.LayerNorm(D_MODEL)
            self.attn = nn.MultiheadAttention(D_MODEL, N_HEADS, batch_first=True)
            self.mlp = nn.Sequential(nn.Linear(D_MODEL, 4 * D_MODEL), nn.GELU(),
                                     nn.Linear(4 * D_MODEL, D_MODEL))
        def forward(self, x):
            h = self.ln1(x)
            x = x + self.attn(h, h, h, need_weights=False)[0]
            return x + self.mlp(self.ln2(x))

    torch.manual_seed(SEED)
    model = nn.Sequential(*[Block() for _ in range(L)]).to(dev, dt).eval()
    x = torch.randn(BSZ, SEQ, D_MODEL, device=dev, dtype=dt)
    store = np.memmap(workdir / "torch_capture.raw", dtype=np.float16, mode="w+",
                      shape=(N_FWD_WRITE * L * BSZ * SEQ, D_MODEL))

    def run(n, capture, to_disk):
        captured, row = [], 0
        hooks = []
        if capture:
            def hook(_m, _i, out):
                captured.append(out)
            hooks = [b.register_forward_hook(hook) for b in model]
        with torch.no_grad():
            for _ in range(WARMUP):
                model(x)
            torch.mps.synchronize()
            t0 = time.perf_counter()
            for _ in range(n):
                captured.clear()
                model(x)
                if to_disk:
                    for c in captured:
                        a = c.to("cpu").numpy().reshape(-1, D_MODEL)
                        store[row:row + a.shape[0]] = a
                        row += a.shape[0]
            torch.mps.synchronize()
            dt_s = time.perf_counter() - t0
        for h in hooks:
            h.remove()
        return dt_s / n

    out = []
    for mode, cap, disk, n in (("plain", False, False, N_FWD),
                               ("retain", True, False, N_FWD),
                               ("retain+copy+write", True, True, N_FWD_WRITE)):
        times = [run(n, cap, disk) for _ in range(REPEATS)]
        out.append({"backend": "torch-mps", "mode": mode,
                    "s_per_forward": times,
                    "tokens_per_s_mean": BSZ * SEQ / np.mean(times)})
        print(f"  torch-mps {mode:22s} {np.mean(times)*1000:8.1f} ms/fwd")
    return out


def bench_mlx(workdir: Path) -> list[dict]:
    import mlx.core as mx
    import mlx.nn as mnn

    class Block(mnn.Module):
        def __init__(self):
            super().__init__()
            self.ln1, self.ln2 = mnn.LayerNorm(D_MODEL), mnn.LayerNorm(D_MODEL)
            self.attn = mnn.MultiHeadAttention(D_MODEL, N_HEADS)
            self.fc1, self.fc2 = mnn.Linear(D_MODEL, 4 * D_MODEL), mnn.Linear(4 * D_MODEL, D_MODEL)
        def __call__(self, x):
            h = self.ln1(x)
            x = x + self.attn(h, h, h)
            return x + self.fc2(mnn.gelu(self.fc1(self.ln2(x))))

    mx.random.seed(SEED)
    blocks = [Block() for _ in range(L)]
    for b in blocks:
        b.set_dtype(mx.float16)
    x = mx.random.normal((BSZ, SEQ, D_MODEL)).astype(mx.float16)
    store = np.memmap(workdir / "mlx_capture.raw", dtype=np.float16, mode="w+",
                      shape=(N_FWD_WRITE * L * BSZ * SEQ, D_MODEL))

    def fwd(capture):
        captured, h = [], x
        for b in blocks:
            h = b(h)
            if capture:
                captured.append(h)
        return h, captured

    def run(n, capture, to_disk):
        row = 0
        for _ in range(WARMUP):
            out, cap = fwd(capture)
            mx.eval(out, *cap)
        t0 = time.perf_counter()
        for _ in range(n):
            out, cap = fwd(capture)
            mx.eval(out, *cap)
            if to_disk:
                for c in cap:
                    a = np.array(c, copy=False).reshape(-1, D_MODEL)
                    store[row:row + a.shape[0]] = a
                    row += a.shape[0]
        return (time.perf_counter() - t0) / n

    out = []
    for mode, cap, disk, n in (("plain", False, False, N_FWD),
                               ("retain", True, False, N_FWD),
                               ("retain+copy+write", True, True, N_FWD_WRITE)):
        times = [run(n, cap, disk) for _ in range(REPEATS)]
        out.append({"backend": "mlx", "mode": mode,
                    "s_per_forward": times,
                    "tokens_per_s_mean": BSZ * SEQ / np.mean(times)})
        print(f"  mlx       {mode:22s} {np.mean(times)*1000:8.1f} ms/fwd")
    return out


# ---------------------------------------------------------------- main
def main() -> int:
    workdir = Path(tempfile.mkdtemp(prefix="modelmap_expH_"))
    print(f"expH run #1 — workdir {workdir}")
    try:
        print("Part A — storage formats (warm cache):")
        storage = bench_storage(workdir)
        print("Part B — capture overhead:")
        compute = bench_torch_mps(workdir) + bench_mlx(workdir)
    finally:
        shutil.rmtree(workdir, ignore_errors=True)

    commit = subprocess.run(["git", "rev-parse", "HEAD"], cwd=ROOT,
                            capture_output=True, text=True, check=False).stdout.strip()
    ts = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
    outdir = ROOT / "results" / "expH_capture_cost_frontier" / ts
    outdir.mkdir(parents=True)
    doc = {
        "experiment": "expH_capture_cost_frontier",
        "run": 1,
        "scope": "storage formats (warm cache) + capture overhead, synthetic model",
        "commit": commit,
        "config": {
            "storage": {"rows": ROWS, "dim": DIM, "write_chunk": WRITE_CHUNK,
                        "batch": BATCH, "n_batches": N_BATCHES, "repeats": REPEATS,
                        "cache": "warm (declared limitation; cold pass = run #2)"},
            "compute": {"layers": L, "d_model": D_MODEL, "heads": N_HEADS,
                        "seq": SEQ, "batch": BSZ, "dtype": "float16",
                        "n_forwards": N_FWD, "warmup": WARMUP, "repeats": REPEATS},
            "seed": SEED,
        },
        "manifest": manifest(),
        "storage": storage,
        "compute": compute,
    }
    (outdir / "results.json").write_text(json.dumps(doc, indent=2) + "\n")
    print(f"results -> {outdir / 'results.json'}")
    return 0


if __name__ == "__main__":
    sys.exit(main())