expH runs #2-#3: first falsified hypothesis + first quantized capture
Run #2 (cold cache, M3U96a, purge/repeat): FALSIFIED — warm ordering inverts; zarr-uncompressed 0.62 GB/s beats raw-mmap 0.14 on cold random batches (page-fault QD1 IO vs 32MiB chunk reads). Rule: IO granularity decides, not the container. Store design revised; run #4 registered. Run #3 (mlx-lm Qwen3-0.6B-4bit): CONFIRMED — retain 1.004x plain prefill (capture free under lazy eval), retain+write 1.28x. First quantized-model activation capture in Python tooling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 7 changed files with +739 and −0
modified
experiments/micro/expH_capture_cost_frontier/analysis.md
+74 −0
@@ -76,3 +76,77 @@ Next experiment : run #2 = cold-cache pass (purge/F_NOCACHE) to test | ||
| 76 | 76 | roughly linearly. |
| 77 | 77 | - All timing cells and raw seconds are in the results JSON with the hardware |
| 78 | 78 | manifest; the figure-ready aggregation is deferred to the atlas pipeline. |
| 79 | + | |
| 80 | +--- | |
| 81 | + | |
| 82 | +# Analysis — expH run #2 (cold cache, second hardware) — **HYPOTHESIS FALSIFIED** | |
| 83 | + | |
| 84 | +Run: `results/expH_capture_cost_frontier/*_run2_M3U96a/results.json` | |
| 85 | +Hardware: Apple M3 Ultra (Mac Studio, MacLustr M3U96a), 96 GB, macOS. | |
| 86 | +Store enlarged to 200k×4096 fp16 (~1.6 GB); `sudo purge` before **every** | |
| 87 | +timed read repetition. Hypothesis registered before the run. | |
| 88 | + | |
| 89 | +```text | |
| 90 | +Hypothesis : warm ordering survives cold — mmap ≥ 1.5× zarr on | |
| 91 | + random-batch reads. | |
| 92 | +Falsification criterion : dies if any zarr variant beats mmap (ratio <1.25×). | |
| 93 | +Result : FALSIFIED — the ordering INVERTS cold. | |
| 94 | + Random-batch (cold): zarr-uncompressed 0.62 GB/s, | |
| 95 | + safetensors(mmap) 0.32, zarr-zstd 0.19, | |
| 96 | + raw-mmap 0.14 → zarr-uncompressed beats mmap 4.4×. | |
| 97 | + Sequential (cold): zarr-uncompressed 3.47, | |
| 98 | + zarr-zstd 3.18, raw-mmap 0.64, safetensors 0.53. | |
| 99 | +Interpretation : Level 0–1, and the mechanism is legible: cold mmap | |
| 100 | + reads are page-fault-driven — effectively ~8–16 KiB | |
| 101 | + random reads at queue depth 1, which localvm's | |
| 102 | + cold-cache SSD grid already showed run at | |
| 103 | + ~0.07–0.14 GB/s. zarr instead read()s whole 32 MiB | |
| 104 | + chunks, exactly the ≥256 KiB / high-QD regime where | |
| 105 | + Apple NVMe delivers. The warm-cache run #1 numbers | |
| 106 | + measured memory, not disk. Cross-project agreement | |
| 107 | + (modelmap run #2 ↔ localvm expH) is strong | |
| 108 | + convergent evidence for the real rule: | |
| 109 | + ***IO granularity decides, not the container.*** | |
| 110 | + Design decision REVISED: for stores larger than | |
| 111 | + RAM, either use chunked containers or access mmap | |
| 112 | + in chunk-aligned batched reads; never row-level | |
| 113 | + random access. Caveat: run #1 was M5 Max, run #2 | |
| 114 | + M3 Ultra — the warm-vs-cold contrast within run #2's | |
| 115 | + own machine is what carries the claim; hardware | |
| 116 | + confound noted for the cross-run comparison. | |
| 117 | +Next experiment : run #4 (registered idea, not yet run): mmap with | |
| 118 | + chunk-sorted, ≥1 MiB batched reads + F_RDAHEAD vs | |
| 119 | + zarr-uncompressed, cold, same machine — decides the | |
| 120 | + final store design. Also replicate run #2 on the | |
| 121 | + M5 Max laptop for the hardware axis. | |
| 122 | +``` | |
| 123 | + | |
| 124 | +--- | |
| 125 | + | |
| 126 | +# Analysis — expH run #3 (real 4-bit checkpoint via mlx-lm) — CONFIRMED | |
| 127 | + | |
| 128 | +Run: `results/expH_capture_cost_frontier/20260812T061742Z/results.json` | |
| 129 | +Model: mlx-community/Qwen3-0.6B-4bit (28 layers, d_model 1024), prefill of a | |
| 130 | +901-token prompt, M5 Max 48 GB. Hypothesis registered before the run. | |
| 131 | + | |
| 132 | +```text | |
| 133 | +Hypothesis : retain ≤ 1.15× plain prefill; retain+write ≤ 1.5×. | |
| 134 | +Result : CONFIRMED, stronger than predicted. | |
| 135 | + plain 28.5 ms/prefill (31,644 tok/s) → | |
| 136 | + retain 28.6 ms (1.004× — capture is FREE) → | |
| 137 | + retain+copy+write 36.4 ms (1.28×, 24,733 tok/s). | |
| 138 | +Interpretation : Level 0–1 (3 repeats, single machine/model). The | |
| 139 | + synthetic run #1 MLX result (1.02×) transfers to a | |
| 140 | + real quantized checkpoint. This run is also the | |
| 141 | + project's first QUANTIZED-model activation capture | |
| 142 | + in Python — the capability the Phase 1 survey found | |
| 143 | + nowhere (notes §4.9). Engineering note: quantized | |
| 144 | + embeddings pack weight shapes, so d_model must be | |
| 145 | + inferred from a runtime activation, and bf16 | |
| 146 | + activations must be cast in MLX before NumPy | |
| 147 | + conversion (numpy has no bfloat16). | |
| 148 | +Next experiment : same three modes at 1.7B/4B to trace the overhead | |
| 149 | + curve vs model size; then wire this Tap pattern | |
| 150 | + into src/modelmap/capture as the standard MLX | |
| 151 | + capture layer (expA depends on it). | |
| 152 | +``` | |
modified
experiments/micro/expH_capture_cost_frontier/hypothesis.md
+56 −0
@@ -63,3 +63,59 @@ case; a purge/F_NOCACHE cold pass is a registered follow-up (run #2), and the | ||
| 63 | 63 | sister project's cold-cache NVMe ceiling (localvm expH: ~13.1 GB/s at |
| 64 | 64 | ≥256 KiB, QD≥4) bounds what run #2 can find. Synthetic model ≠ real |
| 65 | 65 | checkpoint: run #3 will repeat mode timings on a real 0.5B model via mlx-lm. |
| 66 | + | |
| 67 | +--- | |
| 68 | + | |
| 69 | +# Hypothesis — expH run #2 (cold-cache storage, second hardware) | |
| 70 | + | |
| 71 | +Registered 2026-08-12 **before** the run. Host: MacLustr node M3U96a | |
| 72 | +(Mac Studio, 32 cores, 96 GB), where `sudo purge` empties the unified buffer | |
| 73 | +cache between repetitions — also our first cross-hardware replication point. | |
| 74 | + | |
| 75 | +```text | |
| 76 | +Hypothesis : The warm-cache format ordering survives cold: | |
| 77 | + raw mmap sustains ≥ 1.5× zarr-uncompressed on | |
| 78 | + random-batch reads with a purged cache; absolute | |
| 79 | + throughputs drop for all formats; zarr-zstd's | |
| 80 | + relative gap narrows (decode overlaps disk IO) | |
| 81 | + but does not close. | |
| 82 | +Falsification criterion : Dies if any zarr variant matches or beats mmap | |
| 83 | + cold (ratio < 1.25×) — that would rehabilitate | |
| 84 | + chunked stores and overturn run #1's design call. | |
| 85 | +Method : Same grid as run #1 Part A, store enlarged to | |
| 86 | + 200k×4096 fp16 (~1.6 GB), `sudo purge` before | |
| 87 | + every timed read repetition, 3 repeats/cell, | |
| 88 | + writes measured warm (unchanged claim). | |
| 89 | +Baseline / null : run #1 warm numbers on M5 Max; sequential scan | |
| 90 | + as the random-read reference. | |
| 91 | +Result : (pending) | |
| 92 | +Interpretation : (pending) | |
| 93 | +Next experiment : (pending) | |
| 94 | +``` | |
| 95 | + | |
| 96 | +--- | |
| 97 | + | |
| 98 | +# Hypothesis — expH run #3 (real quantized checkpoint via mlx-lm) | |
| 99 | + | |
| 100 | +Registered 2026-08-12 **before** the run. Local M5 Max, model = | |
| 101 | +mlx-community Qwen3-0.6B (4-bit), capture by wrapping decoder layers. | |
| 102 | + | |
| 103 | +```text | |
| 104 | +Hypothesis : On a real 0.6B 4-bit model under MLX, per-layer | |
| 105 | + residual retention costs ≤ 1.15× plain prefill | |
| 106 | + (lazy-graph retention, as in run #1's synthetic | |
| 107 | + result), and retention + NumPy conversion + mmap | |
| 108 | + write stays ≤ 1.5×. | |
| 109 | +Falsification criterion : Dies at retain > 1.5× or retain+write > 2× — | |
| 110 | + would mean synthetic overheads do not transfer to | |
| 111 | + real checkpoints and all run #1 Part B numbers | |
| 112 | + must be re-derived on real models. | |
| 113 | +Method : Prefill of a fixed 1024-token prompt, batch 1, | |
| 114 | + 10 timed forwards after 3 warmups, 3 repeats; | |
| 115 | + modes = plain / retain-all-layers / retain+write; | |
| 116 | + mx.eval barriers around each forward. | |
| 117 | +Baseline / null : plain prefill throughput (tok/s). | |
| 118 | +Result : (pending) | |
| 119 | +Interpretation : (pending) | |
| 120 | +Next experiment : (pending) | |
| 121 | +``` | |
added
experiments/micro/expH_capture_cost_frontier/implementation/benchmark_cold.py
+184 −0
@@ -0,0 +1,184 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ============================================================================= | |
| 3 | +# Project : modelmap | |
| 4 | +# File : experiments/micro/expH_capture_cost_frontier/implementation/benchmark_cold.py | |
| 5 | +# Purpose : Run #2 — cold-cache storage-format throughput (purge per repeat) | |
| 6 | +# Author : Simon-Pierre Boucher | |
| 7 | +# Contact : contact@spboucher.ai | |
| 8 | +# Website : https://modelmap.io | |
| 9 | +# Created : 2026-08-12 | |
| 10 | +# Modified : 2026-08-12 | |
| 11 | +# Platform : macOS / Apple Silicon (arm64) | |
| 12 | +# License : All rights reserved (research code) | |
| 13 | +# ============================================================================= | |
| 14 | +"""expH run #2 (see hypothesis.md, registered before this run). | |
| 15 | + | |
| 16 | +Storage-only cold-cache variant: `sudo purge` empties the unified buffer | |
| 17 | +cache before EVERY timed read repetition. Designed to run standalone on a | |
| 18 | +MacLustr node (numpy + zarr + safetensors only, no torch/mlx). Results JSON | |
| 19 | +is self-contained (embeds a local hardware manifest) and is collected back | |
| 20 | +into results/ by the driver on the laptop. | |
| 21 | + | |
| 22 | +Usage: python3 benchmark_cold.py --workdir /path --out results.json \ | |
| 23 | + [--purge-cmd "sudo -n purge"] | |
| 24 | +""" | |
| 25 | + | |
| 26 | +from __future__ import annotations | |
| 27 | + | |
| 28 | +import argparse | |
| 29 | +import json | |
| 30 | +import platform | |
| 31 | +import shutil | |
| 32 | +import subprocess | |
| 33 | +import time | |
| 34 | +from pathlib import Path | |
| 35 | + | |
| 36 | +import numpy as np | |
| 37 | + | |
| 38 | +REPEATS = 3 | |
| 39 | +ROWS, DIM = 200_000, 4096 # ~1.6 GB fp16 per format | |
| 40 | +WRITE_CHUNK = 4_096 | |
| 41 | +BATCH = 4_096 | |
| 42 | +N_BATCHES = 24 | |
| 43 | +SEED = 0 | |
| 44 | + | |
| 45 | + | |
| 46 | +def sysctl(key: str) -> str: | |
| 47 | + try: | |
| 48 | + return subprocess.run(["sysctl", "-n", key], capture_output=True, | |
| 49 | + text=True, check=True).stdout.strip() | |
| 50 | + except subprocess.CalledProcessError: | |
| 51 | + return "" | |
| 52 | + | |
| 53 | + | |
| 54 | +def local_manifest() -> dict: | |
| 55 | + return { | |
| 56 | + "author": "Simon-Pierre Boucher", | |
| 57 | + "chip": sysctl("machdep.cpu.brand_string"), | |
| 58 | + "cores": int(sysctl("hw.ncpu") or 0), | |
| 59 | + "unified_gb": round(int(sysctl("hw.memsize") or 0) / 2**30, 1), | |
| 60 | + "os": platform.mac_ver()[0], | |
| 61 | + "python": platform.python_version(), | |
| 62 | + "numpy": np.__version__, | |
| 63 | + "host": platform.node(), | |
| 64 | + } | |
| 65 | + | |
| 66 | + | |
| 67 | +def main() -> int: | |
| 68 | + ap = argparse.ArgumentParser() | |
| 69 | + ap.add_argument("--workdir", required=True) | |
| 70 | + ap.add_argument("--out", required=True) | |
| 71 | + ap.add_argument("--purge-cmd", default="") | |
| 72 | + args = ap.parse_args() | |
| 73 | + | |
| 74 | + workdir = Path(args.workdir) | |
| 75 | + workdir.mkdir(parents=True, exist_ok=True) | |
| 76 | + | |
| 77 | + def purge(): | |
| 78 | + if args.purge_cmd: | |
| 79 | + subprocess.run(args.purge_cmd, shell=True, check=True, | |
| 80 | + capture_output=True) | |
| 81 | + | |
| 82 | + def timed_cold(fn): | |
| 83 | + out = [] | |
| 84 | + for _ in range(REPEATS): | |
| 85 | + purge() | |
| 86 | + t0 = time.perf_counter() | |
| 87 | + fn() | |
| 88 | + out.append(time.perf_counter() - t0) | |
| 89 | + return out | |
| 90 | + | |
| 91 | + rng = np.random.default_rng(SEED) | |
| 92 | + data = rng.standard_normal((WRITE_CHUNK, DIM)).astype(np.float16) | |
| 93 | + batches = [rng.integers(0, ROWS, BATCH) for _ in range(N_BATCHES)] | |
| 94 | + total_bytes = ROWS * DIM * 2 | |
| 95 | + batch_bytes = BATCH * DIM * 2 * N_BATCHES | |
| 96 | + results = [] | |
| 97 | + | |
| 98 | + def record(fmt, op, times, nbytes): | |
| 99 | + results.append({"format": fmt, "op": op, "bytes": nbytes, "seconds": times, | |
| 100 | + "gb_per_s_mean": nbytes / 2**30 / np.mean(times), | |
| 101 | + "cache": "cold" if op != "write" else "warm"}) | |
| 102 | + print(f" {fmt:18s} {op:12s} {nbytes/2**30/np.mean(times):8.2f} GB/s", flush=True) | |
| 103 | + | |
| 104 | + # ---- raw np.memmap | |
| 105 | + p = workdir / "acts.raw" | |
| 106 | + def write_raw(): | |
| 107 | + m = np.memmap(p, dtype=np.float16, mode="w+", shape=(ROWS, DIM)) | |
| 108 | + for i in range(0, ROWS, WRITE_CHUNK): | |
| 109 | + end = min(i + WRITE_CHUNK, ROWS) | |
| 110 | + m[i:end] = data[: end - i] | |
| 111 | + m.flush(); del m | |
| 112 | + t0 = time.perf_counter(); write_raw() | |
| 113 | + record("raw-mmap", "write", [time.perf_counter() - t0], total_bytes) | |
| 114 | + def seq_raw(): | |
| 115 | + m = np.memmap(p, dtype=np.float16, mode="r", shape=(ROWS, DIM)) | |
| 116 | + float(np.asarray(m).sum(dtype=np.float32)); del m | |
| 117 | + def rnd_raw(): | |
| 118 | + m = np.memmap(p, dtype=np.float16, mode="r", shape=(ROWS, DIM)) | |
| 119 | + for b in batches: | |
| 120 | + m[b].sum(dtype=np.float32) | |
| 121 | + del m | |
| 122 | + record("raw-mmap", "seq-scan", timed_cold(seq_raw), total_bytes) | |
| 123 | + record("raw-mmap", "random-batch", timed_cold(rnd_raw), batch_bytes) | |
| 124 | + | |
| 125 | + # ---- safetensors | |
| 126 | + from safetensors import safe_open | |
| 127 | + from safetensors.numpy import save_file | |
| 128 | + ps = workdir / "acts.safetensors" | |
| 129 | + full = np.memmap(p, dtype=np.float16, mode="r", shape=(ROWS, DIM)) | |
| 130 | + t0 = time.perf_counter(); save_file({"acts": np.asarray(full)}, str(ps)) | |
| 131 | + record("safetensors", "write", [time.perf_counter() - t0], total_bytes) | |
| 132 | + del full | |
| 133 | + def seq_st(): | |
| 134 | + f = safe_open(str(ps), framework="np") | |
| 135 | + float(f.get_tensor("acts").sum(dtype=np.float32)) | |
| 136 | + def rnd_st(): | |
| 137 | + f = safe_open(str(ps), framework="np") | |
| 138 | + t = f.get_tensor("acts") | |
| 139 | + for b in batches: | |
| 140 | + t[b].sum(dtype=np.float32) | |
| 141 | + record("safetensors", "seq-scan", timed_cold(seq_st), total_bytes) | |
| 142 | + record("safetensors", "random-batch", timed_cold(rnd_st), batch_bytes) | |
| 143 | + | |
| 144 | + # ---- zarr variants | |
| 145 | + import zarr | |
| 146 | + for codec, name in ((None, "zarr-uncompressed"), ("default", "zarr-zstd")): | |
| 147 | + pz = workdir / f"acts_{name}.zarr" | |
| 148 | + kwargs = {} if codec == "default" else {"compressors": None} | |
| 149 | + if pz.exists(): | |
| 150 | + shutil.rmtree(pz) | |
| 151 | + t0 = time.perf_counter() | |
| 152 | + z = zarr.create_array(store=str(pz), shape=(ROWS, DIM), | |
| 153 | + chunks=(WRITE_CHUNK, DIM), dtype=np.float16, **kwargs) | |
| 154 | + for i in range(0, ROWS, WRITE_CHUNK): | |
| 155 | + end = min(i + WRITE_CHUNK, ROWS) | |
| 156 | + z[i:end] = data[: end - i] | |
| 157 | + record(name, "write", [time.perf_counter() - t0], total_bytes) | |
| 158 | + def seq_z(pz=pz): | |
| 159 | + zz = zarr.open_array(store=str(pz), mode="r") | |
| 160 | + float(zz[:].sum(dtype=np.float32)) | |
| 161 | + def rnd_z(pz=pz): | |
| 162 | + zz = zarr.open_array(store=str(pz), mode="r") | |
| 163 | + for b in batches: | |
| 164 | + zz[np.sort(b)].sum(dtype=np.float32) | |
| 165 | + record(name, "seq-scan", timed_cold(seq_z), total_bytes) | |
| 166 | + record(name, "random-batch", timed_cold(rnd_z), batch_bytes) | |
| 167 | + | |
| 168 | + doc = { | |
| 169 | + "experiment": "expH_capture_cost_frontier", | |
| 170 | + "run": 2, | |
| 171 | + "scope": "storage formats, COLD cache (purge per repeat), second hardware", | |
| 172 | + "config": {"rows": ROWS, "dim": DIM, "write_chunk": WRITE_CHUNK, | |
| 173 | + "batch": BATCH, "n_batches": N_BATCHES, "repeats": REPEATS, | |
| 174 | + "purge_cmd": args.purge_cmd or "(none — warm!)", "seed": SEED}, | |
| 175 | + "manifest": local_manifest(), | |
| 176 | + "storage": results, | |
| 177 | + } | |
| 178 | + Path(args.out).write_text(json.dumps(doc, indent=2) + "\n") | |
| 179 | + print(f"results -> {args.out}") | |
| 180 | + return 0 | |
| 181 | + | |
| 182 | + | |
| 183 | +if __name__ == "__main__": | |
| 184 | + raise SystemExit(main()) | |
added
experiments/micro/expH_capture_cost_frontier/implementation/benchmark_real.py
+149 −0
@@ -0,0 +1,149 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ============================================================================= | |
| 3 | +# Project : modelmap | |
| 4 | +# File : experiments/micro/expH_capture_cost_frontier/implementation/benchmark_real.py | |
| 5 | +# Purpose : Run #3 — capture overhead on a real quantized checkpoint (mlx-lm) | |
| 6 | +# Author : Simon-Pierre Boucher | |
| 7 | +# Contact : contact@spboucher.ai | |
| 8 | +# Website : https://modelmap.io | |
| 9 | +# Created : 2026-08-12 | |
| 10 | +# Modified : 2026-08-12 | |
| 11 | +# Platform : macOS / Apple Silicon (arm64) — MLX / Metal | |
| 12 | +# License : All rights reserved (research code) | |
| 13 | +# ============================================================================= | |
| 14 | +"""expH run #3 (see hypothesis.md, registered before this run). | |
| 15 | + | |
| 16 | +Loads a real 4-bit model via mlx-lm, wraps every decoder layer with a | |
| 17 | +retaining tap, and measures prefill throughput in three modes: | |
| 18 | +plain / retain-all-layers / retain + NumPy conversion + mmap write. | |
| 19 | +This is the first quantized-model activation capture in the project — | |
| 20 | +the capability the Phase 1 survey found nowhere in Python tooling. | |
| 21 | +""" | |
| 22 | + | |
| 23 | +from __future__ import annotations | |
| 24 | + | |
| 25 | +import json | |
| 26 | +import shutil | |
| 27 | +import subprocess | |
| 28 | +import sys | |
| 29 | +import tempfile | |
| 30 | +import time | |
| 31 | +from pathlib import Path | |
| 32 | + | |
| 33 | +import numpy as np | |
| 34 | + | |
| 35 | +ROOT = Path(__file__).resolve().parents[4] | |
| 36 | +sys.path.insert(0, str(ROOT / "benchmarks")) | |
| 37 | +from hardware_manifest import manifest | |
| 38 | + | |
| 39 | +MODEL = "mlx-community/Qwen3-0.6B-4bit" | |
| 40 | +SEQ = 1024 | |
| 41 | +N_FWD, WARMUP, REPEATS = 10, 3, 3 | |
| 42 | +SEED = 0 | |
| 43 | + | |
| 44 | + | |
| 45 | +class Tap: | |
| 46 | + """Wraps a decoder layer; optionally retains its output.""" | |
| 47 | + | |
| 48 | + def __init__(self, layer): | |
| 49 | + self.layer = layer | |
| 50 | + self.retained = None | |
| 51 | + self.enabled = False | |
| 52 | + | |
| 53 | + def __call__(self, *args, **kwargs): | |
| 54 | + out = self.layer(*args, **kwargs) | |
| 55 | + if self.enabled: | |
| 56 | + self.retained = out | |
| 57 | + return out | |
| 58 | + | |
| 59 | + def __getattr__(self, name): # delegate attribute access (e.g. .self_attn) | |
| 60 | + return getattr(self.layer, name) | |
| 61 | + | |
| 62 | + | |
| 63 | +def main() -> int: | |
| 64 | + import mlx.core as mx | |
| 65 | + from mlx_lm import load | |
| 66 | + | |
| 67 | + mx.random.seed(SEED) | |
| 68 | + model, tokenizer = load(MODEL) | |
| 69 | + layers = model.model.layers | |
| 70 | + n_layers = len(layers) | |
| 71 | + taps = [Tap(l) for l in layers] | |
| 72 | + model.model.layers = taps | |
| 73 | + | |
| 74 | + text = ("The internal cartography of local language models requires " | |
| 75 | + "systematic measurement of every layer. ") * 60 | |
| 76 | + tokens = tokenizer.encode(text)[:SEQ] | |
| 77 | + x = mx.array([tokens]) | |
| 78 | + | |
| 79 | + # infer d_model at runtime (quantized embeddings pack their weight shapes) | |
| 80 | + taps[0].enabled = True | |
| 81 | + mx.eval(model(x)) | |
| 82 | + d_model = int(taps[0].retained.shape[-1]) | |
| 83 | + taps[0].enabled = False | |
| 84 | + taps[0].retained = None | |
| 85 | + print(f"model={MODEL} layers={n_layers} d_model={d_model} seq={len(tokens)}") | |
| 86 | + | |
| 87 | + workdir = Path(tempfile.mkdtemp(prefix="modelmap_expH3_")) | |
| 88 | + store = np.memmap(workdir / "capture.raw", dtype=np.float16, mode="w+", | |
| 89 | + shape=(N_FWD * n_layers * len(tokens), d_model)) | |
| 90 | + | |
| 91 | + def run(capture: bool, to_disk: bool) -> float: | |
| 92 | + for t in taps: | |
| 93 | + t.enabled = capture | |
| 94 | + t.retained = None | |
| 95 | + row = 0 | |
| 96 | + for _ in range(WARMUP): | |
| 97 | + out = model(x) | |
| 98 | + mx.eval(out, *[t.retained for t in taps if t.retained is not None]) | |
| 99 | + t0 = time.perf_counter() | |
| 100 | + for _ in range(N_FWD): | |
| 101 | + out = model(x) | |
| 102 | + retained = [t.retained for t in taps] if capture else [] | |
| 103 | + mx.eval(out, *[r for r in retained if r is not None]) | |
| 104 | + if to_disk: | |
| 105 | + for r in retained: | |
| 106 | + # model runs bf16 — cast in MLX (numpy has no bfloat16) | |
| 107 | + a = np.array(r.astype(mx.float16), copy=False).reshape(-1, d_model) | |
| 108 | + store[row:row + a.shape[0]] = a | |
| 109 | + row += a.shape[0] | |
| 110 | + return (time.perf_counter() - t0) / N_FWD | |
| 111 | + | |
| 112 | + results = [] | |
| 113 | + try: | |
| 114 | + for mode, cap, disk in (("plain", False, False), | |
| 115 | + ("retain", True, False), | |
| 116 | + ("retain+copy+write", True, True)): | |
| 117 | + times = [run(cap, disk) for _ in range(REPEATS)] | |
| 118 | + results.append({"backend": "mlx-lm", "mode": mode, | |
| 119 | + "s_per_forward": times, | |
| 120 | + "tokens_per_s_mean": len(tokens) / np.mean(times)}) | |
| 121 | + print(f" {mode:22s} {np.mean(times)*1000:8.1f} ms/prefill " | |
| 122 | + f"({len(tokens)/np.mean(times):8.0f} tok/s)") | |
| 123 | + finally: | |
| 124 | + store.flush() | |
| 125 | + shutil.rmtree(workdir, ignore_errors=True) | |
| 126 | + | |
| 127 | + commit = subprocess.run(["git", "rev-parse", "HEAD"], cwd=ROOT, | |
| 128 | + capture_output=True, text=True, check=False).stdout.strip() | |
| 129 | + ts = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) | |
| 130 | + outdir = ROOT / "results" / "expH_capture_cost_frontier" / ts | |
| 131 | + outdir.mkdir(parents=True) | |
| 132 | + doc = { | |
| 133 | + "experiment": "expH_capture_cost_frontier", | |
| 134 | + "run": 3, | |
| 135 | + "scope": "capture overhead on a real 4-bit checkpoint (mlx-lm prefill)", | |
| 136 | + "commit": commit, | |
| 137 | + "config": {"model": MODEL, "seq": len(tokens), "n_layers": n_layers, | |
| 138 | + "d_model": int(d_model), "n_forwards": N_FWD, | |
| 139 | + "warmup": WARMUP, "repeats": REPEATS, "seed": SEED}, | |
| 140 | + "manifest": manifest(), | |
| 141 | + "compute": results, | |
| 142 | + } | |
| 143 | + (outdir / "results.json").write_text(json.dumps(doc, indent=2) + "\n") | |
| 144 | + print(f"results -> {outdir / 'results.json'}") | |
| 145 | + return 0 | |
| 146 | + | |
| 147 | + | |
| 148 | +if __name__ == "__main__": | |
| 149 | + sys.exit(main()) | |
modified
research/LOG.md
+37 −0
@@ -240,3 +240,40 @@ mlx-lm); then expA first run (hypothesis registered: dataset variance > | ||
| 240 | 240 | seed variance; harness validity gate = selectivity<0.05 on random-init). |
| 241 | 241 | expA's output becomes the first real map card. Replicate expH grid on |
| 242 | 242 | M3U96a/M2U64 to move the cost table cross-hardware. |
| 243 | + | |
| 244 | +--- | |
| 245 | + | |
| 246 | +## 2026-08-12 06:30 EDT — expH runs #2 & #3: first FALSIFIED hypothesis + first quantized capture | |
| 247 | + | |
| 248 | +**Questions.** Does the warm-cache storage ordering survive a cold cache | |
| 249 | +(run #2, on M3U96a with `sudo purge` per repetition — also the first | |
| 250 | +cross-hardware point)? Do synthetic capture overheads transfer to a real | |
| 251 | +4-bit checkpoint (run #3, mlx-lm Qwen3-0.6B-4bit)? | |
| 252 | + | |
| 253 | +**Run #2 — FALSIFIED (the project's first registered-and-killed | |
| 254 | +hypothesis).** Cold random-batch reads on M3 Ultra: zarr-uncompressed | |
| 255 | +0.62 GB/s > safetensors-mmap 0.32 > zarr-zstd 0.19 > raw-mmap 0.14 — | |
| 256 | +the warm ordering (mmap 3.2–10.8× ahead) fully INVERTS. Mechanism: cold | |
| 257 | +mmap is page-fault IO (~8–16 KiB, QD1), exactly the regime localvm's SSD | |
| 258 | +grid measured at ~0.07–0.14 GB/s; zarr read()s 32 MiB chunks — the | |
| 259 | +≥256 KiB regime where Apple NVMe delivers. Run #1 measured memory, not | |
| 260 | +disk. **The rule is IO granularity, not the container.** Store design | |
| 261 | +revised: chunk-aligned batched access mandatory for larger-than-RAM | |
| 262 | +stores; run #4 registered (chunk-sorted ≥1 MiB mmap reads vs zarr, cold). | |
| 263 | +Cross-project convergence with localvm expH is strong — the bridge works | |
| 264 | +in both directions. | |
| 265 | + | |
| 266 | +**Run #3 — CONFIRMED, stronger than predicted.** Real Qwen3-0.6B-4bit | |
| 267 | +prefill (901 tokens, 28 layers): plain 28.5 ms → retain 28.6 ms | |
| 268 | +(**1.004× — capture is free under MLX lazy evaluation**) → retain+cast+ | |
| 269 | +mmap-write 36.4 ms (1.28×). First quantized-model activation capture in | |
| 270 | +Python tooling (the §4.9 gap, now demonstrated). Engineering notes: | |
| 271 | +d_model must be inferred from runtime activations (quantized embeddings | |
| 272 | +pack weights); bf16 must be cast in MLX before NumPy. | |
| 273 | + | |
| 274 | +**Decisions.** (1) Promote the Tap-wrapper pattern into | |
| 275 | +src/modelmap/capture as the standard MLX capture layer — expA depends on | |
| 276 | +it. (2) The published cost table will carry cache-regime as a first-class | |
| 277 | +dimension (warm ≠ cold columns), and run #2's negative goes on the site's | |
| 278 | +negative-results ledger once the atlas pipeline exists. (3) expA first | |
| 279 | +run is now unblocked (capture layer + probes + stats all in place). | |
added
results/expH_capture_cost_frontier/20260812T061742Z/results.json
+75 −0
@@ -0,0 +1,75 @@ | ||
| 1 | +{ | |
| 2 | + "experiment": "expH_capture_cost_frontier", | |
| 3 | + "run": 3, | |
| 4 | + "scope": "capture overhead on a real 4-bit checkpoint (mlx-lm prefill)", | |
| 5 | + "commit": "f3dd60ef6bed19c96456381481abb0aa50b83b73", | |
| 6 | + "config": { | |
| 7 | + "model": "mlx-community/Qwen3-0.6B-4bit", | |
| 8 | + "seq": 901, | |
| 9 | + "n_layers": 28, | |
| 10 | + "d_model": 1024, | |
| 11 | + "n_forwards": 10, | |
| 12 | + "warmup": 3, | |
| 13 | + "repeats": 3, | |
| 14 | + "seed": 0 | |
| 15 | + }, | |
| 16 | + "manifest": { | |
| 17 | + "author": "Simon-Pierre Boucher", | |
| 18 | + "contact": "contact@spboucher.ai", | |
| 19 | + "website": "https://modelmap.io", | |
| 20 | + "chip": { | |
| 21 | + "brand": "Apple M5 Max", | |
| 22 | + "cores_total": 18, | |
| 23 | + "cores_performance": 6, | |
| 24 | + "cores_efficiency": 12 | |
| 25 | + }, | |
| 26 | + "memory": { | |
| 27 | + "unified_gb": 48.0, | |
| 28 | + "pagesize": 16384 | |
| 29 | + }, | |
| 30 | + "os": { | |
| 31 | + "system": "Darwin", | |
| 32 | + "version": "27.0", | |
| 33 | + "arch": "arm64" | |
| 34 | + }, | |
| 35 | + "software": { | |
| 36 | + "python": "3.14.4", | |
| 37 | + "numpy": "2.5.2", | |
| 38 | + "mlx": "0.32.0", | |
| 39 | + "torch": "2.13.0", | |
| 40 | + "safetensors": "0.8.0" | |
| 41 | + } | |
| 42 | + }, | |
| 43 | + "compute": [ | |
| 44 | + { | |
| 45 | + "backend": "mlx-lm", | |
| 46 | + "mode": "plain", | |
| 47 | + "s_per_forward": [ | |
| 48 | + 0.02832533750006405, | |
| 49 | + 0.028470029100026294, | |
| 50 | + 0.02862457089995587 | |
| 51 | + ], | |
| 52 | + "tokens_per_s_mean": 31643.66632788203 | |
| 53 | + }, | |
| 54 | + { | |
| 55 | + "backend": "mlx-lm", | |
| 56 | + "mode": "retain", | |
| 57 | + "s_per_forward": [ | |
| 58 | + 0.028357170900017083, | |
| 59 | + 0.028654929099866423, | |
| 60 | + 0.028654850000020816 | |
| 61 | + ], | |
| 62 | + "tokens_per_s_mean": 31552.424826645736 | |
| 63 | + }, | |
| 64 | + { | |
| 65 | + "backend": "mlx-lm", | |
| 66 | + "mode": "retain+copy+write", | |
| 67 | + "s_per_forward": [ | |
| 68 | + 0.03822784169988154, | |
| 69 | + 0.0356740749999517, | |
| 70 | + 0.03538348749989382 | |
| 71 | + ], | |
| 72 | + "tokens_per_s_mean": 24733.40351159859 | |
| 73 | + } | |
| 74 | + ] | |
| 75 | +} | |
added
results/expH_capture_cost_frontier/20260812T061824Z_run2_M3U96a/results.json
+164 −0
@@ -0,0 +1,164 @@ | ||
| 1 | +{ | |
| 2 | + "experiment": "expH_capture_cost_frontier", | |
| 3 | + "run": 2, | |
| 4 | + "scope": "storage formats, COLD cache (purge per repeat), second hardware", | |
| 5 | + "config": { | |
| 6 | + "rows": 200000, | |
| 7 | + "dim": 4096, | |
| 8 | + "write_chunk": 4096, | |
| 9 | + "batch": 4096, | |
| 10 | + "n_batches": 24, | |
| 11 | + "repeats": 3, | |
| 12 | + "purge_cmd": "echo 492592 | sudo -S purge", | |
| 13 | + "seed": 0 | |
| 14 | + }, | |
| 15 | + "manifest": { | |
| 16 | + "author": "Simon-Pierre Boucher", | |
| 17 | + "chip": "Apple M3 Ultra", | |
| 18 | + "cores": 28, | |
| 19 | + "unified_gb": 96.0, | |
| 20 | + "os": "26.6", | |
| 21 | + "python": "3.14.5", | |
| 22 | + "numpy": "2.5.2", | |
| 23 | + "host": "MacStudnpierre2" | |
| 24 | + }, | |
| 25 | + "storage": [ | |
| 26 | + { | |
| 27 | + "format": "raw-mmap", | |
| 28 | + "op": "write", | |
| 29 | + "bytes": 1638400000, | |
| 30 | + "seconds": [ | |
| 31 | + 0.9577836249954998 | |
| 32 | + ], | |
| 33 | + "gb_per_s_mean": 1.5931353036623166, | |
| 34 | + "cache": "warm" | |
| 35 | + }, | |
| 36 | + { | |
| 37 | + "format": "raw-mmap", | |
| 38 | + "op": "seq-scan", | |
| 39 | + "bytes": 1638400000, | |
| 40 | + "seconds": [ | |
| 41 | + 2.311261749942787, | |
| 42 | + 2.483271957957186, | |
| 43 | + 2.3612381250131875 | |
| 44 | + ], | |
| 45 | + "gb_per_s_mean": 0.6397125042046533, | |
| 46 | + "cache": "cold" | |
| 47 | + }, | |
| 48 | + { | |
| 49 | + "format": "raw-mmap", | |
| 50 | + "op": "random-batch", | |
| 51 | + "bytes": 805306368, | |
| 52 | + "seconds": [ | |
| 53 | + 5.556866124970838, | |
| 54 | + 5.391185167012736, | |
| 55 | + 4.8900218330090865 | |
| 56 | + ], | |
| 57 | + "gb_per_s_mean": 0.14206273593026125, | |
| 58 | + "cache": "cold" | |
| 59 | + }, | |
| 60 | + { | |
| 61 | + "format": "safetensors", | |
| 62 | + "op": "write", | |
| 63 | + "bytes": 1638400000, | |
| 64 | + "seconds": [ | |
| 65 | + 1.8997217500582337 | |
| 66 | + ], | |
| 67 | + "gb_per_s_mean": 0.8032117894124369, | |
| 68 | + "cache": "warm" | |
| 69 | + }, | |
| 70 | + { | |
| 71 | + "format": "safetensors", | |
| 72 | + "op": "seq-scan", | |
| 73 | + "bytes": 1638400000, | |
| 74 | + "seconds": [ | |
| 75 | + 2.492816833080724, | |
| 76 | + 2.518700583023019, | |
| 77 | + 3.5813771249959245 | |
| 78 | + ], | |
| 79 | + "gb_per_s_mean": 0.5327234841362526, | |
| 80 | + "cache": "cold" | |
| 81 | + }, | |
| 82 | + { | |
| 83 | + "format": "safetensors", | |
| 84 | + "op": "random-batch", | |
| 85 | + "bytes": 805306368, | |
| 86 | + "seconds": [ | |
| 87 | + 2.43777041696012, | |
| 88 | + 2.4196586669422686, | |
| 89 | + 2.2487994579132646 | |
| 90 | + ], | |
| 91 | + "gb_per_s_mean": 0.31662364737640725, | |
| 92 | + "cache": "cold" | |
| 93 | + }, | |
| 94 | + { | |
| 95 | + "format": "zarr-uncompressed", | |
| 96 | + "op": "write", | |
| 97 | + "bytes": 1638400000, | |
| 98 | + "seconds": [ | |
| 99 | + 0.7715847919462249 | |
| 100 | + ], | |
| 101 | + "gb_per_s_mean": 1.9775906966765944, | |
| 102 | + "cache": "warm" | |
| 103 | + }, | |
| 104 | + { | |
| 105 | + "format": "zarr-uncompressed", | |
| 106 | + "op": "seq-scan", | |
| 107 | + "bytes": 1638400000, | |
| 108 | + "seconds": [ | |
| 109 | + 0.43666258302982897, | |
| 110 | + 0.4407244159374386, | |
| 111 | + 0.4407702499302104 | |
| 112 | + ], | |
| 113 | + "gb_per_s_mean": 3.4727546524352753, | |
| 114 | + "cache": "cold" | |
| 115 | + }, | |
| 116 | + { | |
| 117 | + "format": "zarr-uncompressed", | |
| 118 | + "op": "random-batch", | |
| 119 | + "bytes": 805306368, | |
| 120 | + "seconds": [ | |
| 121 | + 1.3796995830489323, | |
| 122 | + 1.139681207947433, | |
| 123 | + 1.1073772499803454 | |
| 124 | + ], | |
| 125 | + "gb_per_s_mean": 0.6203887809935233, | |
| 126 | + "cache": "cold" | |
| 127 | + }, | |
| 128 | + { | |
| 129 | + "format": "zarr-zstd", | |
| 130 | + "op": "write", | |
| 131 | + "bytes": 1638400000, | |
| 132 | + "seconds": [ | |
| 133 | + 1.9146007499657571 | |
| 134 | + ], | |
| 135 | + "gb_per_s_mean": 0.7969697631620015, | |
| 136 | + "cache": "warm" | |
| 137 | + }, | |
| 138 | + { | |
| 139 | + "format": "zarr-zstd", | |
| 140 | + "op": "seq-scan", | |
| 141 | + "bytes": 1638400000, | |
| 142 | + "seconds": [ | |
| 143 | + 0.47764337505213916, | |
| 144 | + 0.4796952080214396, | |
| 145 | + 0.48077000002376735 | |
| 146 | + ], | |
| 147 | + "gb_per_s_mean": 3.183095332684026, | |
| 148 | + "cache": "cold" | |
| 149 | + }, | |
| 150 | + { | |
| 151 | + "format": "zarr-zstd", | |
| 152 | + "op": "random-batch", | |
| 153 | + "bytes": 805306368, | |
| 154 | + "seconds": [ | |
| 155 | + 3.880511499941349, | |
| 156 | + 4.199512624996714, | |
| 157 | + 3.8756266250275075 | |
| 158 | + ], | |
| 159 | + "gb_per_s_mean": 0.18819552754219418, | |
| 160 | + "cache": "cold" | |
| 161 | + } | |
| 162 | + ], | |
| 163 | + "commit": "f3dd60ef6bed19c96456381481abb0aa50b83b73" | |
| 164 | +} | |
| \ No newline at end of file | ||
| 165 | ||