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%
7.0 KB · 181 lines python
Raw Blame History
1#!/usr/bin/env python32# =============================================================================3#  Project   : localvm-research4#  File      : experiments/micro/expF_error_accumulation/benchmark.py5#  Purpose   : Layer-sensitivity map — degrade-one / repair-one / repair-top-k6#              (does layer-restricted escalation cut bytes-per-escalation?)7#  Author    : Simon-Pierre Boucher8#  Contact   : contact@spboucher.ai9#  Created   : 2026-08-1210#  Modified  : 2026-08-1211#  Platform  : macOS / Apple Silicon (arm64) — MLX / Metal12#  License   : All rights reserved (research code)13# =============================================================================14"""Experiment F — error accumulation / layer sensitivity (charter §9.F).1516Per depth-group 4-bit degradation and repair on Qwen3-1.7B, teacher-forced17over reference greedy trajectories.1819Usage:20    .venv/bin/python benchmark.py [--per-domain 8] [--gen-tokens 128] [--groups 7]21"""2223from __future__ import annotations2425import argparse26import json27import sys28import time29from datetime import datetime, timezone30from pathlib import Path3132import mlx.core as mx33import mlx.nn as nn34import numpy as np35from mlx_lm import load3637REPO_ROOT = Path(__file__).resolve().parents[3]38sys.path.insert(0, str(REPO_ROOT / "benchmarks"))39sys.path.insert(0, str(REPO_ROOT / "src"))40from hardware_manifest import collect_manifest  # noqa: E40241from localvm.quality.decision_stats import greedy_generate, kl_ref_vs, teacher_forced_stats  # noqa: E4024243GROUP_SIZE = 6444BITS = 4454647def layer_index(path: str) -> int | None:48    parts = path.split(".")49    for i, p in enumerate(parts):50        if p == "layers" and i + 1 < len(parts) and parts[i + 1].isdigit():51            return int(parts[i + 1])52    return None535455def quantized_weights_by_layer(model) -> dict[str, tuple[int, mx.array]]:56    """{param_path: (layer_idx, 4-bit-dequantized bf16 weight)} for all57    divisible Linear layers inside transformer blocks."""58    out = {}59    for path, module in model.named_modules():60        li = layer_index(path)61        if li is None or not isinstance(module, nn.Linear):62            continue63        if module.weight.shape[-1] % GROUP_SIZE != 0:64            continue65        w = module.weight.astype(mx.float32)66        qw, sc, bi = mx.quantize(w, group_size=GROUP_SIZE, bits=BITS)67        deq = mx.dequantize(qw, sc, bi, group_size=GROUP_SIZE, bits=BITS).astype(mx.bfloat16)68        mx.eval(deq)69        out[path] = (li, deq)70    return out717273def apply_config(model, qweights: dict, originals: dict, degrade_layers: set[int]) -> None:74    """Set each eligible Linear to 4-bit dequant if its layer ∈ degrade_layers,75    else restore the original bf16 weight."""76    for path, module in model.named_modules():77        if path in qweights:78            li, deq = qweights[path]79            module.weight = deq if li in degrade_layers else originals[path]808182def evaluate(model, trajectories, ref_stats) -> dict:83    agrees, kls = [], []84    for t, ref in zip(trajectories, ref_stats):85        qs = teacher_forced_stats(model, t["full_ids"], t["start"])86        ref_next = np.array(t["full_ids"][t["start"]:])87        agrees.append((qs["argmax"] == ref_next).astype(np.int8))88        kls.append(kl_ref_vs(qs["logprobs"], ref["logprobs"]))89    return {90        "agreement_rate": float(np.concatenate(agrees).mean()),91        "mean_kl": float(np.mean(np.concatenate(kls))),92    }939495def main() -> None:96    ap = argparse.ArgumentParser()97    ap.add_argument("--model", default="mlx-community/Qwen3-1.7B-bf16")98    ap.add_argument("--gen-tokens", type=int, default=128)99    ap.add_argument("--per-domain", type=int, default=8)100    ap.add_argument("--groups", type=int, default=7)101    args = ap.parse_args()102103    domains = json.loads((REPO_ROOT / "benchmarks/datasets/eval_prompts.json").read_text())["domains"]104    print(f"loading {args.model} …", flush=True)105    model, tokenizer = load(args.model)106    n_layers = len(model.model.layers)107    bounds = np.linspace(0, n_layers, args.groups + 1).astype(int)108    groups = [set(range(bounds[i], bounds[i + 1])) for i in range(args.groups)]109110    trajectories = []111    t0 = time.time()112    for domain, plist in domains.items():113        for prompt in plist[: args.per_domain]:114            ids = tokenizer.apply_chat_template(115                [{"role": "user", "content": prompt}], add_generation_prompt=True)116            gen = greedy_generate(model, tokenizer, ids, args.gen_tokens)117            if len(gen) >= 8:118                trajectories.append({"domain": domain, "full_ids": list(ids) + gen, "start": len(ids)})119    print(f"{len(trajectories)} trajectories in {time.time()-t0:.0f}s", flush=True)120    ref_stats = [teacher_forced_stats(model, t["full_ids"], t["start"]) for t in trajectories]121122    print("precomputing 4-bit weights …", flush=True)123    qweights = quantized_weights_by_layer(model)124    originals = {p: m.weight for p, m in model.named_modules() if p in qweights}125    all_layers = set(range(n_layers))126127    runs: dict[str, dict] = {}128129    def run(tag: str, degrade: set[int]) -> dict:130        apply_config(model, qweights, originals, degrade)131        r = evaluate(model, trajectories, ref_stats)132        r["degraded_layers"] = sorted(degrade)133        runs[tag] = r134        print(f"  {tag:>24}: agree={r['agreement_rate']:.4f} KL={r['mean_kl']:.4f}", flush=True)135        return r136137    print("all-4-bit floor:", flush=True)138    floor = run("all_4bit", all_layers)139    print("degrade-one (rest bf16):", flush=True)140    for gi, g in enumerate(groups):141        run(f"degrade_g{gi}_L{min(g)}-{max(g)}", g)142    print("repair-one (rest 4-bit):", flush=True)143    for gi, g in enumerate(groups):144        run(f"repair_g{gi}_L{min(g)}-{max(g)}", all_layers - g)145146    # repair-top-k by measured repair value147    lost = 1.0 - floor["agreement_rate"]148    repair_value = {149        gi: runs[f"repair_g{gi}_L{min(g)}-{max(g)}"]["agreement_rate"] - floor["agreement_rate"]150        for gi, g in enumerate(groups)151    }152    order = sorted(repair_value, key=repair_value.get, reverse=True)153    print("repair-top-k (best groups bf16):", flush=True)154    for k in (2, 3):155        keep = set().union(*(groups[gi] for gi in order[:k]))156        run(f"repair_top{k}_groups_{sorted(order[:k])}", all_layers - keep)157158    apply_config(model, qweights, originals, set())  # restore159160    ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")161    out_dir = REPO_ROOT / "results" / "expF_error_accumulation" / ts162    out_dir.mkdir(parents=True)163    (out_dir / "results.json").write_text(json.dumps({164        "experiment": "expF_error_accumulation",165        "author": "Simon-Pierre Boucher",166        "contact": "contact@spboucher.ai",167        "manifest": collect_manifest(),168        "config": vars(args),169        "bits": BITS, "group_size": GROUP_SIZE,170        "n_layers": n_layers,171        "layer_groups": [sorted(g) for g in groups],172        "agreement_lost_all4bit": lost,173        "repair_value_by_group": {str(k): v for k, v in repair_value.items()},174        "runs": runs,175    }, indent=2))176    print(f"\nwrote {out_dir / 'results.json'}")177178179if __name__ == "__main__":180    main()181