Code / experiments/candidate_01/implementation/streaming_verifier.py

experiments/candidate_01/implementation/streaming_verifier.py 86 lines
# =============================================================================
#  Project   : localvm-research
#  File      : experiments/candidate_01/implementation/streaming_verifier.py
#  Purpose   : Layer-streamed q8 verification for models larger than free RAM —
#              per-layer materialize → compute → re-lazify on unified memory
#  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)
# =============================================================================
"""StreamingVerifier — runs a verification forward pass through a quantized
model whose weights do NOT fit in free memory alongside the resident base.

Mechanism: the model is built with lazy (mmap-backed) weights. During a
forward pass we walk the layers manually; each layer's weights materialize
on first use, and immediately after the layer's output is evaluated we
re-assign that layer's parameters to FRESH lazy arrays (a new mx.load view),
dropping the concrete buffers. Peak residency ≈ resident base + a few
layers, while the SSD sees one sequential pass over the checkpoint per
sweep — exactly the expH-friendly access pattern.
"""

from __future__ import annotations

import glob
import time
from pathlib import Path

import mlx.core as mx
from mlx_lm import load as mlx_load
from mlx_lm.models.base import create_attention_mask


class StreamingVerifier:
    def __init__(self, model_path: str):
        self.path = Path(model_path)
        # lazy=True: parameters are mmap-backed lazy arrays, nothing evaluated
        self.model, self.tokenizer = mlx_load(str(model_path), lazy=True)
        self.shards = sorted(glob.glob(str(self.path / "*.safetensors")))
        self.weight_bytes = sum(Path(s).stat().st_size for s in self.shards)
        self.last_sweep_io_s = 0.0

    def _fresh_lazy_weights(self) -> dict:
        w = {}
        for s in self.shards:
            w.update(mx.load(s))  # lazy by default: no eval performed
        return w

    def _relazify(self, weights: dict, prefix: str) -> None:
        subset = [(k, v) for k, v in weights.items() if k.startswith(prefix)]
        if subset:
            self.model.load_weights(subset, strict=False)

    def forward_chunk(self, chunk_ids: list[int], cache) -> mx.array:
        """Teacher-force `chunk_ids` through the model with per-layer weight
        streaming. `cache` is a make_prompt_cache(self.model) list; it is
        advanced by len(chunk_ids). Returns logits (T, vocab)."""
        t0 = time.perf_counter()
        fresh = self._fresh_lazy_weights()
        inner = self.model.model
        x = mx.array(chunk_ids)[None]
        h = inner.embed_tokens(x)
        mx.eval(h)
        self._relazify(fresh, "model.embed_tokens")
        mask = create_attention_mask(h, cache)
        for i, layer in enumerate(inner.layers):
            h = layer(h, mask, cache=cache[i] if cache else None)
            mx.eval(h)
            self._relazify(fresh, f"model.layers.{i}.")
            if (i + 1) % 8 == 0:
                mx.clear_cache()  # release Metal allocator pools
        h = inner.norm(h)
        if hasattr(self.model, "lm_head"):
            logits = self.model.lm_head(h)
        else:  # tied embeddings
            logits = inner.embed_tokens.as_linear(h)
        logits = logits[0]
        mx.eval(logits)
        self._relazify(fresh, "model.norm")
        self._relazify(fresh, "lm_head")
        mx.clear_cache()
        self.last_sweep_io_s = time.perf_counter() - t0
        return logits