|
1 |
+#!/usr/bin/env python3 |
| 1 |
2 |
# ============================================================================= |
| 2 |
3 |
# Project : localvm-research |
| 3 |
4 |
# File : experiments/micro/expF_error_accumulation/benchmark.py |
| 4 |
|
−# Purpose : Benchmark runner: Error accumulation: which layers tolerate, amplify, or recover from controlled approximation |
|
5 |
+# Purpose : Layer-sensitivity map — degrade-one / repair-one / repair-top-k |
|
6 |
+# (does layer-restricted escalation cut bytes-per-escalation?) |
| 5 |
7 |
# Author : Simon-Pierre Boucher |
| 6 |
8 |
# Contact : contact@spboucher.ai |
| 7 |
|
−# Created : 2026-08-11 |
| 8 |
|
−# Modified : 2026-08-11 |
| 9 |
|
−# Platform : macOS / Apple Silicon (arm64) |
|
9 |
+# Created : 2026-08-12 |
|
10 |
+# Modified : 2026-08-12 |
|
11 |
+# Platform : macOS / Apple Silicon (arm64) — MLX / Metal |
| 10 |
12 |
# License : All rights reserved (research code) |
| 11 |
13 |
# ============================================================================= |
|
14 |
+"""Experiment F — error accumulation / layer sensitivity (charter §9.F). |
| 12 |
15 |
|
| 13 |
|
−"""Benchmark entry point for expF_error_accumulation. |
|
16 |
+Per depth-group 4-bit degradation and repair on Qwen3-1.7B, teacher-forced |
|
17 |
+over reference greedy trajectories. |
| 14 |
18 |
|
| 15 |
|
−Must embed the hardware manifest in all result output |
| 16 |
|
−(see benchmarks/hardware_manifest.py) and write results to |
| 17 |
|
−results/expF_error_accumulation/<timestamp>/. |
|
19 |
+Usage: |
|
20 |
+ .venv/bin/python benchmark.py [--per-domain 8] [--gen-tokens 128] [--groups 7] |
| 18 |
21 |
""" |
| 19 |
22 |
|
|
23 |
+from __future__ import annotations |
|
24 |
+ |
|
25 |
+import argparse |
|
26 |
+import json |
| 20 |
27 |
import sys |
|
28 |
+import time |
|
29 |
+from datetime import datetime, timezone |
| 21 |
30 |
from pathlib import Path |
| 22 |
31 |
|
| 23 |
|
−sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "benchmarks")) |
|
32 |
+import mlx.core as mx |
|
33 |
+import mlx.nn as nn |
|
34 |
+import numpy as np |
|
35 |
+from mlx_lm import load |
|
36 |
+ |
|
37 |
+REPO_ROOT = Path(__file__).resolve().parents[3] |
|
38 |
+sys.path.insert(0, str(REPO_ROOT / "benchmarks")) |
|
39 |
+sys.path.insert(0, str(REPO_ROOT / "src")) |
| 24 |
40 |
from hardware_manifest import collect_manifest # noqa: E402 |
|
41 |
+from localvm.quality.decision_stats import greedy_generate, kl_ref_vs, teacher_forced_stats # noqa: E402 |
|
42 |
+ |
|
43 |
+GROUP_SIZE = 64 |
|
44 |
+BITS = 4 |
|
45 |
+ |
|
46 |
+ |
|
47 |
+def 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 None |
|
53 |
+ |
|
54 |
+ |
|
55 |
+def quantized_weights_by_layer(model) -> dict[str, tuple[int, mx.array]]: |
|
56 |
+ """{param_path: (layer_idx, 4-bit-dequantized bf16 weight)} for all |
|
57 |
+ 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 |
+ continue |
|
63 |
+ if module.weight.shape[-1] % GROUP_SIZE != 0: |
|
64 |
+ continue |
|
65 |
+ 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 out |
|
71 |
+ |
|
72 |
+ |
|
73 |
+def 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] |
|
80 |
+ |
|
81 |
+ |
|
82 |
+def 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 |
+ } |
| 25 |
93 |
|
| 26 |
94 |
|
| 27 |
95 |
def main() -> None: |
| 28 |
|
− manifest = collect_manifest() |
| 29 |
|
− raise NotImplementedError("experiment not yet implemented") |
|
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() |
|
102 |
+ |
|
103 |
+ 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)] |
|
109 |
+ |
|
110 |
+ 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] |
|
121 |
+ |
|
122 |
+ 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)) |
|
126 |
+ |
|
127 |
+ runs: dict[str, dict] = {} |
|
128 |
+ |
|
129 |
+ 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] = r |
|
134 |
+ print(f" {tag:>24}: agree={r['agreement_rate']:.4f} KL={r['mean_kl']:.4f}", flush=True) |
|
135 |
+ return r |
|
136 |
+ |
|
137 |
+ 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) |
|
145 |
+ |
|
146 |
+ # repair-top-k by measured repair value |
|
147 |
+ 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) |
|
157 |
+ |
|
158 |
+ apply_config(model, qweights, originals, set()) # restore |
|
159 |
+ |
|
160 |
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") |
|
161 |
+ out_dir = REPO_ROOT / "results" / "expF_error_accumulation" / ts |
|
162 |
+ 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'}") |
| 30 |
177 |
|
| 31 |
178 |
|
| 32 |
179 |
if __name__ == "__main__": |