Code / experiments/micro/expA_weight_concentration/benchmark.py

experiments/micro/expA_weight_concentration/benchmark.py 191 lines
#!/usr/bin/env python3
# =============================================================================
#  Project   : localvm-research
#  File      : experiments/micro/expA_weight_concentration/benchmark.py
#  Purpose   : Per-token FFN block-energy concentration (trace shared with expB)
#  Author    : Simon-Pierre Boucher
#  Contact   : contact@spboucher.ai
#  Created   : 2026-08-12
#  Modified  : 2026-08-12
#  Platform  : macOS / Apple Silicon (arm64) — MLX / Metal
#  License   : All rights reserved (research code)
# =============================================================================
"""Experiment A — weight contribution concentration (charter §9.A).

Records SwiGLU intermediate-activation energy per 64-neuron block, per token,
per layer, on the bf16 reference model. Writes concentration aggregates to
results.json and the raw block-energy trace (npz) for expB.

Usage:
    .venv/bin/python benchmark.py [--per-domain 8] [--gen-tokens 128] [--block 64]
"""

from __future__ import annotations

import argparse
import json
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

import mlx.core as mx
import mlx.nn as nn
import numpy as np
from mlx_lm import load

REPO_ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(REPO_ROOT / "benchmarks"))
sys.path.insert(0, str(REPO_ROOT / "src"))
from hardware_manifest import collect_manifest  # noqa: E402
from localvm.quality.decision_stats import greedy_generate  # noqa: E402


class DownProjRecorder(nn.Module):
    """Wraps a down_proj Linear; records block energy of its input (the SwiGLU
    intermediate activation) for positions in `window`."""

    def __init__(self, inner: nn.Module, block: int):
        super().__init__()
        self.inner = inner
        self.block = block
        self.window: tuple[int, int] | None = None
        self.block_energy: np.ndarray | None = None
        self.neuron_energy: np.ndarray | None = None

    def __call__(self, x):
        if self.window is not None:
            a, b = self.window
            h = x[0, a:b].astype(mx.float32)
            e = mx.square(h)
            be = e.reshape(h.shape[0], -1, self.block).sum(axis=-1)
            # normalize per position: raw energies overflow float16 storage,
            # and only relative importance matters for expA/expB
            be = be / (be.sum(axis=-1, keepdims=True) + 1e-12)
            mx.eval(be)
            self.block_energy = np.array(be)
            self.neuron_energy = np.array(e)  # (T, D_int) — reduced by caller
        return self.inner(x)


def concentration_stats(energy: np.ndarray, fracs=(0.1, 0.2, 0.4, 0.6),
                        targets=(0.90, 0.95, 0.99)) -> dict:
    """energy: (T, N). Returns mean energy captured by top-f fraction and mean
    fraction of units needed to reach target energy."""
    T, N = energy.shape
    srt = np.sort(energy, axis=1)[:, ::-1]
    csum = np.cumsum(srt, axis=1)
    total = csum[:, -1:] + 1e-12
    frac_captured = {}
    for f in fracs:
        k = max(1, int(round(f * N)))
        frac_captured[f] = float(np.mean(csum[:, k - 1] / total[:, 0]))
    needed = {}
    ratio = csum / total
    for t in targets:
        idx = np.argmax(ratio >= t, axis=1) + 1
        needed[t] = float(np.mean(idx / N))
    return {"top_frac_energy": frac_captured, "frac_needed_for": needed}


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--model", default="mlx-community/Qwen3-1.7B-bf16")
    ap.add_argument("--gen-tokens", type=int, default=128)
    ap.add_argument("--per-domain", type=int, default=8)
    ap.add_argument("--block", type=int, default=16,
                    help="trace granularity; analysis also derives 4x-coarser blocks")
    args = ap.parse_args()

    domains = json.loads((REPO_ROOT / "benchmarks/datasets/eval_prompts.json").read_text())["domains"]
    print(f"loading {args.model} …", flush=True)
    model, tokenizer = load(args.model)
    layers = model.model.layers
    n_layers = len(layers)

    recorders = []
    for layer in layers:
        rec = DownProjRecorder(layer.mlp.down_proj, args.block)
        layer.mlp.down_proj = rec
        recorders.append(rec)

    trajectories = []
    t0 = time.time()
    for domain, plist in domains.items():
        for prompt in plist[: args.per_domain]:
            ids = tokenizer.apply_chat_template(
                [{"role": "user", "content": prompt}], add_generation_prompt=True)
            for r in recorders:
                r.window = None  # no recording during generation
            gen = greedy_generate(model, tokenizer, ids, args.gen_tokens)
            if len(gen) >= 8:
                trajectories.append({"domain": domain, "full_ids": list(ids) + gen, "start": len(ids)})
    print(f"{len(trajectories)} trajectories in {time.time()-t0:.0f}s", flush=True)

    block_traces = []   # per traj: (T, n_layers, n_blocks) f16
    neuron_stats = []   # per traj per layer concentration dicts
    index = []
    for ti, t in enumerate(trajectories):
        a, b = t["start"] - 1, len(t["full_ids"]) - 1
        for r in recorders:
            r.window = (a, b)
        model(mx.array(t["full_ids"])[None])
        per_layer_blocks = np.stack([r.block_energy for r in recorders], axis=1)  # (T, L, B)
        block_traces.append(per_layer_blocks.astype(np.float16))
        neuron_stats.append([concentration_stats(r.neuron_energy) for r in recorders])
        for r in recorders:
            r.neuron_energy = None
        index.append({"traj": ti, "domain": t["domain"], "n_pos": b - a})
        if (ti + 1) % 12 == 0:
            print(f"  traced {ti+1}/{len(trajectories)}", flush=True)

    all_blocks = np.concatenate(block_traces, axis=0)  # (P, L, B)
    P, L, B = all_blocks.shape
    print(f"trace shape {all_blocks.shape}", flush=True)

    # expA aggregates at trace granularity and 4x-coarser derived granularity
    coarse = all_blocks.reshape(P, L, B // 4, 4).astype(np.float32).sum(axis=-1)
    per_layer = [concentration_stats(all_blocks[:, li, :].astype(np.float32)) for li in range(L)]
    overall = concentration_stats(all_blocks.reshape(P * L, B).astype(np.float32))
    overall_coarse = concentration_stats(coarse.reshape(P * L, B // 4))
    per_domain = {}
    pos_domain = np.concatenate([[ix["domain"]] * ix["n_pos"] for ix in index])
    for dom in domains:
        sel = all_blocks[pos_domain == dom]
        per_domain[dom] = concentration_stats(sel.reshape(-1, B).astype(np.float32))
    # neuron-granularity mean across trajectories/layers
    neuron_overall = {
        "top_frac_energy": {f: float(np.mean([s["top_frac_energy"][f] for ns in neuron_stats for s in ns]))
                            for f in (0.1, 0.2, 0.4, 0.6)},
        "frac_needed_for": {t: float(np.mean([s["frac_needed_for"][t] for ns in neuron_stats for s in ns]))
                            for t in (0.90, 0.95, 0.99)},
    }

    ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    out_dir = REPO_ROOT / "results" / "expA_weight_concentration" / ts
    out_dir.mkdir(parents=True)
    np.savez_compressed(out_dir / "block_energy_trace.npz",
                        blocks=all_blocks,
                        domains=pos_domain,
                        traj_id=np.concatenate([[ix["traj"]] * ix["n_pos"] for ix in index]))
    (out_dir / "results.json").write_text(json.dumps({
        "experiment": "expA_weight_concentration",
        "author": "Simon-Pierre Boucher",
        "contact": "contact@spboucher.ai",
        "manifest": collect_manifest(),
        "config": vars(args),
        "n_positions": int(P), "n_layers": int(L), "n_blocks": int(B),
        "coarse_block_granularity": {"block_size": args.block * 4, "overall": overall_coarse},
        "block_granularity": {"overall": overall,
                              "per_layer": {str(i): s for i, s in enumerate(per_layer)},
                              "per_domain": per_domain},
        "neuron_granularity": neuron_overall,
    }, indent=2, default=float))
    print(f"\nwrote {out_dir}/results.json (+ block_energy_trace.npz for expB)")
    print("overall block-64:", json.dumps(overall, default=float))
    print("neuron-level    :", json.dumps(neuron_overall, default=float))


if __name__ == "__main__":
    main()