SPB Git

spb/localvm-research Public License

Running LLMs larger than memory on a consumer Mac — falsification-driven research: margin-gated deferred refinement, out-of-core verification on Apple Silicon. TR-01 published.

Python 63.2% JavaScript 23.5% CSS 11.8% Shell 0.9% Makefile 0.5%
11.5 KB · 318 lines python
Raw Blame History
1#!/usr/bin/env python32# =============================================================================3#  Project   : localvm-research4#  File      : experiments/micro/expH_ssd_feasibility/benchmark.py5#  Purpose   : Measure real SSD read behavior on macOS/APFS (F_NOCACHE, block6#              sizes, random vs sequential, threads, concurrent Metal load)7#  Author    : Simon-Pierre Boucher8#  Contact   : contact@spboucher.ai9#  Created   : 2026-08-1110#  Modified  : 2026-08-1111#  Platform  : macOS / Apple Silicon (arm64)12#  License   : All rights reserved (research code)13# =============================================================================14"""Experiment H — SSD feasibility (charter §9.H).1516Measures actual (not theoretical) read throughput of the internal Apple NVMe17at block sizes 4 KB … 4 MB, sequential vs random, with and without the page18cache (F_NOCACHE — macOS has no O_DIRECT), 1/4/8 threads, and optionally19under concurrent Metal GPU compute (MLX matmul loop).2021Writes results JSON (with embedded hardware manifest) to22results/expH_ssd_feasibility/<timestamp>/results.json.2324Usage:25    python3 benchmark.py [--file-gib 8] [--repeats 3] [--quick]26"""2728from __future__ import annotations2930import argparse31import fcntl32import json33import os34import statistics35import sys36import threading37import time38from datetime import datetime, timezone39from pathlib import Path4041REPO_ROOT = Path(__file__).resolve().parents[3]42sys.path.insert(0, str(REPO_ROOT / "benchmarks"))43from hardware_manifest import collect_manifest  # noqa: E4024445F_NOCACHE = 48  # from <sys/fcntl.h> on macOS46BLOCK_SIZES = [4 << 10, 16 << 10, 64 << 10, 256 << 10, 1 << 20, 4 << 20]47THREAD_COUNTS = [1, 4, 8]484950def create_test_file(path: Path, size_bytes: int) -> None:51    """Write an incompressible test file WITHOUT populating the page cache.5253    Crucial macOS detail (discovered in the first run of this experiment):54    F_NOCACHE on a *read* fd does not bypass pages already resident in the55    unified page cache — and writing the file normally makes every page56    resident. The file must therefore be written with F_NOCACHE set on the57    write fd, so 'cold' reads afterwards genuinely hit the SSD.58    """59    if path.exists():60        path.unlink()  # always recreate: an old file may have cached pages61    import numpy as np6263    rng = np.random.default_rng(42)64    chunk = rng.integers(0, 256, size=64 << 20, dtype=np.uint8).tobytes()65    written = 066    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)67    try:68        fcntl.fcntl(fd, F_NOCACHE, 1)69        while written < size_bytes:70            n = min(len(chunk), size_bytes - written)71            os.write(fd, chunk[:n])72            written += n73        os.fsync(fd)74    finally:75        os.close(fd)767778def _reader(path: Path, offsets: list[int], block: int, nocache: bool,79            out: dict, idx: int) -> None:80    fd = os.open(path, os.O_RDONLY)81    try:82        if nocache:83            fcntl.fcntl(fd, F_NOCACHE, 1)84        total = 085        t0 = time.perf_counter()86        for off in offsets:87            total += len(os.pread(fd, block, off))88        dt = time.perf_counter() - t089        out[idx] = (total, dt)90    finally:91        os.close(fd)929394def measure(path: Path, file_size: int, block: int, pattern: str,95            nocache: bool, threads: int, budget_bytes: int, seed: int) -> dict:96    """One measurement cell. Returns MB/s and IOPS."""97    import random as _random9899    n_blocks_total = max(1, budget_bytes // block)100    max_off = file_size - block101    rng = _random.Random(seed)102    if pattern == "random":103        all_offsets = [rng.randrange(0, max_off // block) * block for _ in range(n_blocks_total)]104    else:105        stride = max(block, (max_off // n_blocks_total) // block * block) if n_blocks_total > 1 else block106        all_offsets = [(i * block) % (max_off + 1) for i in range(n_blocks_total)]107        del stride108109    per_thread = [all_offsets[i::threads] for i in range(threads)]110    out: dict = {}111    ts = [112        threading.Thread(target=_reader, args=(path, per_thread[i], block, nocache, out, i))113        for i in range(threads)114    ]115    t0 = time.perf_counter()116    for t in ts:117        t.start()118    for t in ts:119        t.join()120    wall = time.perf_counter() - t0121    total_bytes = sum(v[0] for v in out.values())122    return {123        "mb_per_s": total_bytes / wall / 1e6,124        "iops": total_bytes / block / wall,125        "bytes": total_bytes,126        "wall_s": wall,127    }128129130def warm_cache(path: Path) -> None:131    with open(path, "rb") as f:132        while f.read(64 << 20):133            pass134135136def adaptive_budget(path: Path, file_size: int, block: int, pattern: str,137                    nocache: bool, threads: int, floor: int,138                    target_s: float = 3.0) -> int:139    """Choose a per-cell byte budget so each repeat runs ~target_s of wall140    time (short cells are dominated by thread-start overhead and timer141    granularity — the first quick run produced impossible 48 GB/s cells at142    ~10 ms wall)."""143    probe = measure(path, file_size, block, pattern, nocache, threads,144                    budget_bytes=max(floor // 8, 32 << 20), seed=7)145    rate = probe["mb_per_s"] * 1e6146    budget = int(rate * target_s)147    return max(floor, min(budget, 8 << 30))148149150class IostatLogger:151    """Log `iostat -d -w 1` for the duration of the run — ground truth for152    whether bytes actually came from the disk controller vs the page cache."""153154    def __init__(self, out_path: Path) -> None:155        self.out_path = out_path156        self.proc: object = None157158    def __enter__(self) -> "IostatLogger":159        import subprocess160161        self._fh = open(self.out_path, "w")162        self.proc = subprocess.Popen(163            ["iostat", "-d", "-w", "1"], stdout=self._fh, stderr=subprocess.DEVNULL164        )165        return self166167    def __exit__(self, *exc: object) -> None:168        self.proc.terminate()169        self._fh.close()170171172class GpuLoad:173    """Background MLX matmul loop to contend for unified memory bandwidth."""174175    def __init__(self) -> None:176        self.stop = threading.Event()177        self.iterations = 0178        self.thread: threading.Thread | None = None179        self.available = False180        try:181            import mlx.core as mx182183            self._mx = mx184            self.available = True185        except ImportError:186            pass187188    def _loop(self) -> None:189        mx = self._mx190        a = mx.random.normal((4096, 4096), dtype=mx.float16)191        b = mx.random.normal((4096, 4096), dtype=mx.float16)192        while not self.stop.is_set():193            c = mx.matmul(a, b)194            mx.eval(c)195            self.iterations += 1196197    def __enter__(self) -> "GpuLoad":198        if self.available:199            self.thread = threading.Thread(target=self._loop, daemon=True)200            self.thread.start()201            time.sleep(0.5)  # let the GPU ramp202        return self203204    def __exit__(self, *exc: object) -> None:205        self.stop.set()206        if self.thread:207            self.thread.join(timeout=10)208209210def main() -> None:211    ap = argparse.ArgumentParser()212    ap.add_argument("--file-gib", type=float, default=8.0)213    ap.add_argument("--repeats", type=int, default=3)214    ap.add_argument("--budget-mib", type=int, default=1024,215                    help="bytes read per measurement cell")216    ap.add_argument("--quick", action="store_true",217                    help="1 repeat, 512 MiB budget, threads {1,8}")218    args = ap.parse_args()219    if args.quick:220        args.repeats, args.budget_mib = 1, 512221        thread_counts = [1, 8]222    else:223        thread_counts = THREAD_COUNTS224225    file_size = int(args.file_gib * (1 << 30))226    test_file = Path(__file__).parent / "results" / "testfile.bin"227    test_file.parent.mkdir(exist_ok=True)228    print(f"creating {args.file_gib} GiB test file (once)…", flush=True)229    create_test_file(test_file, file_size)230231    manifest = collect_manifest()232    results: list[dict] = []233    budget = args.budget_mib << 20234235    ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")236    out_dir = REPO_ROOT / "results" / "expH_ssd_feasibility" / ts237    out_dir.mkdir(parents=True)238    iostat = IostatLogger(out_dir / "iostat.log")239    iostat.__enter__()240241    def run_cells(gpu_loaded: bool) -> None:242        for nocache in (True, False):243            if not nocache:244                warm_cache(test_file)245            for pattern in ("random", "sequential"):246                for block in BLOCK_SIZES:247                    for threads in thread_counts:248                        if gpu_loaded and (pattern != "random" or not nocache or threads != 8):249                            continue  # GPU-load condition: key cells only250                        cell_budget = (251                            budget if args.quick else adaptive_budget(252                                test_file, file_size, block, pattern, nocache,253                                threads, floor=budget)254                        )255                        runs = [256                            measure(test_file, file_size, block, pattern, nocache,257                                    threads, cell_budget, seed=1000 + r)258                            for r in range(args.repeats)259                        ]260                        mbps = [r["mb_per_s"] for r in runs]261                        cell = {262                            "block_bytes": block,263                            "pattern": pattern,264                            "nocache": nocache,265                            "threads": threads,266                            "gpu_load": gpu_loaded,267                            "repeats": args.repeats,268                            "budget_bytes": cell_budget,269                            "mb_per_s_mean": statistics.mean(mbps),270                            "mb_per_s_median": statistics.median(mbps),271                            "mb_per_s_std": statistics.stdev(mbps) if len(mbps) > 1 else 0.0,272                            "iops_mean": statistics.mean(r["iops"] for r in runs),273                        }274                        results.append(cell)275                        print(276                            f"{'GPU+' if gpu_loaded else '    '}"277                            f"{pattern:>10} {block >> 10:>5} KiB "278                            f"nocache={int(nocache)} t={threads}: "279                            f"{cell['mb_per_s_mean']:9.1f} MB/s",280                            flush=True,281                        )282283    run_cells(gpu_loaded=False)284285    gpu = GpuLoad()286    gpu_iters = 0287    if gpu.available:288        # the warm-cache phase above populated the page cache; recreate the289        # file (uncached write) so the GPU-load cells are genuinely cold290        print("recreating test file to evict cached pages before GPU-load phase…", flush=True)291        create_test_file(test_file, file_size)292        print("re-running key cells under concurrent Metal (MLX) matmul load…", flush=True)293        with gpu:294            run_cells(gpu_loaded=True)295        gpu_iters = gpu.iterations296    else:297        print("MLX not available — skipping GPU-load condition", flush=True)298299    iostat.__exit__()300    payload = {301        "experiment": "expH_ssd_feasibility",302        "author": "Simon-Pierre Boucher",303        "contact": "contact@spboucher.ai",304        "manifest": manifest,305        "config": vars(args),306        "test_file_bytes": file_size,307        "gpu_load_matmul_iterations": gpu_iters,308        "thermal_level_after": collect_manifest()["thermal_level_at_collect"],309        "cells": results,310    }311    out_path = out_dir / "results.json"312    out_path.write_text(json.dumps(payload, indent=2))313    print(f"\nwrote {out_path}")314315316if __name__ == "__main__":317    main()318