Code / experiments/micro/expH_ssd_feasibility/benchmark.py
experiments/micro/expH_ssd_feasibility/benchmark.py
318 lines
#!/usr/bin/env python3
# =============================================================================
# Project : localvm-research
# File : experiments/micro/expH_ssd_feasibility/benchmark.py
# Purpose : Measure real SSD read behavior on macOS/APFS (F_NOCACHE, block
# sizes, random vs sequential, threads, concurrent Metal load)
# Author : Simon-Pierre Boucher
# Contact : contact@spboucher.ai
# Created : 2026-08-11
# Modified : 2026-08-11
# Platform : macOS / Apple Silicon (arm64)
# License : All rights reserved (research code)
# =============================================================================
"""Experiment H — SSD feasibility (charter §9.H).
Measures actual (not theoretical) read throughput of the internal Apple NVMe
at block sizes 4 KB … 4 MB, sequential vs random, with and without the page
cache (F_NOCACHE — macOS has no O_DIRECT), 1/4/8 threads, and optionally
under concurrent Metal GPU compute (MLX matmul loop).
Writes results JSON (with embedded hardware manifest) to
results/expH_ssd_feasibility/<timestamp>/results.json.
Usage:
python3 benchmark.py [--file-gib 8] [--repeats 3] [--quick]
"""
from __future__ import annotations
import argparse
import fcntl
import json
import os
import statistics
import sys
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(REPO_ROOT / "benchmarks"))
from hardware_manifest import collect_manifest # noqa: E402
F_NOCACHE = 48 # from <sys/fcntl.h> on macOS
BLOCK_SIZES = [4 << 10, 16 << 10, 64 << 10, 256 << 10, 1 << 20, 4 << 20]
THREAD_COUNTS = [1, 4, 8]
def create_test_file(path: Path, size_bytes: int) -> None:
"""Write an incompressible test file WITHOUT populating the page cache.
Crucial macOS detail (discovered in the first run of this experiment):
F_NOCACHE on a *read* fd does not bypass pages already resident in the
unified page cache — and writing the file normally makes every page
resident. The file must therefore be written with F_NOCACHE set on the
write fd, so 'cold' reads afterwards genuinely hit the SSD.
"""
if path.exists():
path.unlink() # always recreate: an old file may have cached pages
import numpy as np
rng = np.random.default_rng(42)
chunk = rng.integers(0, 256, size=64 << 20, dtype=np.uint8).tobytes()
written = 0
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
try:
fcntl.fcntl(fd, F_NOCACHE, 1)
while written < size_bytes:
n = min(len(chunk), size_bytes - written)
os.write(fd, chunk[:n])
written += n
os.fsync(fd)
finally:
os.close(fd)
def _reader(path: Path, offsets: list[int], block: int, nocache: bool,
out: dict, idx: int) -> None:
fd = os.open(path, os.O_RDONLY)
try:
if nocache:
fcntl.fcntl(fd, F_NOCACHE, 1)
total = 0
t0 = time.perf_counter()
for off in offsets:
total += len(os.pread(fd, block, off))
dt = time.perf_counter() - t0
out[idx] = (total, dt)
finally:
os.close(fd)
def measure(path: Path, file_size: int, block: int, pattern: str,
nocache: bool, threads: int, budget_bytes: int, seed: int) -> dict:
"""One measurement cell. Returns MB/s and IOPS."""
import random as _random
n_blocks_total = max(1, budget_bytes // block)
max_off = file_size - block
rng = _random.Random(seed)
if pattern == "random":
all_offsets = [rng.randrange(0, max_off // block) * block for _ in range(n_blocks_total)]
else:
stride = max(block, (max_off // n_blocks_total) // block * block) if n_blocks_total > 1 else block
all_offsets = [(i * block) % (max_off + 1) for i in range(n_blocks_total)]
del stride
per_thread = [all_offsets[i::threads] for i in range(threads)]
out: dict = {}
ts = [
threading.Thread(target=_reader, args=(path, per_thread[i], block, nocache, out, i))
for i in range(threads)
]
t0 = time.perf_counter()
for t in ts:
t.start()
for t in ts:
t.join()
wall = time.perf_counter() - t0
total_bytes = sum(v[0] for v in out.values())
return {
"mb_per_s": total_bytes / wall / 1e6,
"iops": total_bytes / block / wall,
"bytes": total_bytes,
"wall_s": wall,
}
def warm_cache(path: Path) -> None:
with open(path, "rb") as f:
while f.read(64 << 20):
pass
def adaptive_budget(path: Path, file_size: int, block: int, pattern: str,
nocache: bool, threads: int, floor: int,
target_s: float = 3.0) -> int:
"""Choose a per-cell byte budget so each repeat runs ~target_s of wall
time (short cells are dominated by thread-start overhead and timer
granularity — the first quick run produced impossible 48 GB/s cells at
~10 ms wall)."""
probe = measure(path, file_size, block, pattern, nocache, threads,
budget_bytes=max(floor // 8, 32 << 20), seed=7)
rate = probe["mb_per_s"] * 1e6
budget = int(rate * target_s)
return max(floor, min(budget, 8 << 30))
class IostatLogger:
"""Log `iostat -d -w 1` for the duration of the run — ground truth for
whether bytes actually came from the disk controller vs the page cache."""
def __init__(self, out_path: Path) -> None:
self.out_path = out_path
self.proc: object = None
def __enter__(self) -> "IostatLogger":
import subprocess
self._fh = open(self.out_path, "w")
self.proc = subprocess.Popen(
["iostat", "-d", "-w", "1"], stdout=self._fh, stderr=subprocess.DEVNULL
)
return self
def __exit__(self, *exc: object) -> None:
self.proc.terminate()
self._fh.close()
class GpuLoad:
"""Background MLX matmul loop to contend for unified memory bandwidth."""
def __init__(self) -> None:
self.stop = threading.Event()
self.iterations = 0
self.thread: threading.Thread | None = None
self.available = False
try:
import mlx.core as mx
self._mx = mx
self.available = True
except ImportError:
pass
def _loop(self) -> None:
mx = self._mx
a = mx.random.normal((4096, 4096), dtype=mx.float16)
b = mx.random.normal((4096, 4096), dtype=mx.float16)
while not self.stop.is_set():
c = mx.matmul(a, b)
mx.eval(c)
self.iterations += 1
def __enter__(self) -> "GpuLoad":
if self.available:
self.thread = threading.Thread(target=self._loop, daemon=True)
self.thread.start()
time.sleep(0.5) # let the GPU ramp
return self
def __exit__(self, *exc: object) -> None:
self.stop.set()
if self.thread:
self.thread.join(timeout=10)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--file-gib", type=float, default=8.0)
ap.add_argument("--repeats", type=int, default=3)
ap.add_argument("--budget-mib", type=int, default=1024,
help="bytes read per measurement cell")
ap.add_argument("--quick", action="store_true",
help="1 repeat, 512 MiB budget, threads {1,8}")
args = ap.parse_args()
if args.quick:
args.repeats, args.budget_mib = 1, 512
thread_counts = [1, 8]
else:
thread_counts = THREAD_COUNTS
file_size = int(args.file_gib * (1 << 30))
test_file = Path(__file__).parent / "results" / "testfile.bin"
test_file.parent.mkdir(exist_ok=True)
print(f"creating {args.file_gib} GiB test file (once)…", flush=True)
create_test_file(test_file, file_size)
manifest = collect_manifest()
results: list[dict] = []
budget = args.budget_mib << 20
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
out_dir = REPO_ROOT / "results" / "expH_ssd_feasibility" / ts
out_dir.mkdir(parents=True)
iostat = IostatLogger(out_dir / "iostat.log")
iostat.__enter__()
def run_cells(gpu_loaded: bool) -> None:
for nocache in (True, False):
if not nocache:
warm_cache(test_file)
for pattern in ("random", "sequential"):
for block in BLOCK_SIZES:
for threads in thread_counts:
if gpu_loaded and (pattern != "random" or not nocache or threads != 8):
continue # GPU-load condition: key cells only
cell_budget = (
budget if args.quick else adaptive_budget(
test_file, file_size, block, pattern, nocache,
threads, floor=budget)
)
runs = [
measure(test_file, file_size, block, pattern, nocache,
threads, cell_budget, seed=1000 + r)
for r in range(args.repeats)
]
mbps = [r["mb_per_s"] for r in runs]
cell = {
"block_bytes": block,
"pattern": pattern,
"nocache": nocache,
"threads": threads,
"gpu_load": gpu_loaded,
"repeats": args.repeats,
"budget_bytes": cell_budget,
"mb_per_s_mean": statistics.mean(mbps),
"mb_per_s_median": statistics.median(mbps),
"mb_per_s_std": statistics.stdev(mbps) if len(mbps) > 1 else 0.0,
"iops_mean": statistics.mean(r["iops"] for r in runs),
}
results.append(cell)
print(
f"{'GPU+' if gpu_loaded else ' '}"
f"{pattern:>10} {block >> 10:>5} KiB "
f"nocache={int(nocache)} t={threads}: "
f"{cell['mb_per_s_mean']:9.1f} MB/s",
flush=True,
)
run_cells(gpu_loaded=False)
gpu = GpuLoad()
gpu_iters = 0
if gpu.available:
# the warm-cache phase above populated the page cache; recreate the
# file (uncached write) so the GPU-load cells are genuinely cold
print("recreating test file to evict cached pages before GPU-load phase…", flush=True)
create_test_file(test_file, file_size)
print("re-running key cells under concurrent Metal (MLX) matmul load…", flush=True)
with gpu:
run_cells(gpu_loaded=True)
gpu_iters = gpu.iterations
else:
print("MLX not available — skipping GPU-load condition", flush=True)
iostat.__exit__()
payload = {
"experiment": "expH_ssd_feasibility",
"author": "Simon-Pierre Boucher",
"contact": "contact@spboucher.ai",
"manifest": manifest,
"config": vars(args),
"test_file_bytes": file_size,
"gpu_load_matmul_iterations": gpu_iters,
"thermal_level_after": collect_manifest()["thermal_level_at_collect"],
"cells": results,
}
out_path = out_dir / "results.json"
out_path.write_text(json.dumps(payload, indent=2))
print(f"\nwrote {out_path}")
if __name__ == "__main__":
main()