Image-Text-to-Text
MLX
English
apple-silicon
Mixture of Experts
mixture-of-experts
vision-language
gemma
falcon-perception
inference
Instructions to use waltgrace/mlx-expert-sniper with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- MLX
How to use waltgrace/mlx-expert-sniper with MLX:
# Make sure mlx-vlm is installed # pip install --upgrade mlx-vlm from mlx_vlm import load, generate from mlx_vlm.prompt_utils import apply_chat_template from mlx_vlm.utils import load_config # Load the model model, processor = load("waltgrace/mlx-expert-sniper") config = load_config("waltgrace/mlx-expert-sniper") # Prepare input image = ["http://images.cocodataset.org/val2017/000000039769.jpg"] prompt = "Describe this image." # Apply chat template formatted_prompt = apply_chat_template( processor, config, prompt, num_images=1 ) # Generate output output = generate(model, processor, formatted_prompt, image) print(output) - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- LM Studio
- Atomic Chat
v0.1.0: MoE expert sniping for MLX — run models larger than your RAM
Browse files- .gitignore +6 -0
- README.md +81 -0
- pyproject.toml +39 -0
- src/mlx_expert_sniper/__init__.py +18 -0
- src/mlx_expert_sniper/cache.py +206 -0
- src/mlx_expert_sniper/cli.py +331 -0
- src/mlx_expert_sniper/config.py +88 -0
- src/mlx_expert_sniper/generate.py +80 -0
- src/mlx_expert_sniper/preprocess.py +234 -0
- src/mlx_expert_sniper/profile.py +93 -0
- src/mlx_expert_sniper/server.py +167 -0
- src/mlx_expert_sniper/sniper.py +305 -0
- stream_preprocess.py +236 -0
.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.egg-info/
|
| 3 |
+
dist/
|
| 4 |
+
build/
|
| 5 |
+
*.pyc
|
| 6 |
+
.eggs/
|
README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CLI Agent — `mlx-expert-sniper`
|
| 2 |
+
|
| 3 |
+
Pip-installable CLI that wraps the Expert Sniper research into a production tool.
|
| 4 |
+
|
| 5 |
+
## Verified Results (M4 Mac Mini, 16 GB)
|
| 6 |
+
|
| 7 |
+
| Metric | Value |
|
| 8 |
+
|--------|-------|
|
| 9 |
+
| Model | Qwen3-30B-A3B, 17.2 GB at 4-bit |
|
| 10 |
+
| Standard mlx_lm | OOM |
|
| 11 |
+
| **Sniper steady-state** | **4.22–4.68 tok/s** |
|
| 12 |
+
| Cache hit rate | 85% (cold start) → 88.5% (warm) |
|
| 13 |
+
| RAM used | 0.87 GB pinned |
|
| 14 |
+
| Output | Coherent code, math, essays |
|
| 15 |
+
|
| 16 |
+
## Install
|
| 17 |
+
|
| 18 |
+
```bash
|
| 19 |
+
cd research/expert-sniper/cli-agent
|
| 20 |
+
pip install -e .
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
## Usage
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
# Preprocess model (one-time, ~17 GB on disk)
|
| 27 |
+
mlx-sniper preprocess <hf-model-dir> -o ~/models/qwen3-30b
|
| 28 |
+
|
| 29 |
+
# Or use the streaming preprocessor (downloads one shard at a time):
|
| 30 |
+
python3 stream_preprocess.py
|
| 31 |
+
|
| 32 |
+
# Generate
|
| 33 |
+
mlx-sniper run ~/models/qwen3-30b -p "What is 2+2?" -v
|
| 34 |
+
|
| 35 |
+
# Interactive chat
|
| 36 |
+
mlx-sniper chat ~/models/qwen3-30b
|
| 37 |
+
|
| 38 |
+
# OpenAI-compatible server
|
| 39 |
+
mlx-sniper server ~/models/qwen3-30b --port 8899
|
| 40 |
+
|
| 41 |
+
# Profile performance
|
| 42 |
+
mlx-sniper profile ~/models/qwen3-30b --tokens 100
|
| 43 |
+
|
| 44 |
+
# Show model info
|
| 45 |
+
mlx-sniper info ~/models/qwen3-30b
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
## Files
|
| 49 |
+
|
| 50 |
+
| File | Purpose |
|
| 51 |
+
|------|---------|
|
| 52 |
+
| `src/mlx_expert_sniper/sniper.py` | Core engine — the proven forward pass with expert sniping |
|
| 53 |
+
| `src/mlx_expert_sniper/cache.py` | Per-expert LRU cache + pread/F_NOCACHE binary reader |
|
| 54 |
+
| `src/mlx_expert_sniper/config.py` | SniperConfig dataclass |
|
| 55 |
+
| `src/mlx_expert_sniper/generate.py` | High-level generate / stream_generate |
|
| 56 |
+
| `src/mlx_expert_sniper/preprocess.py` | Convert HuggingFace model → sniper binary format |
|
| 57 |
+
| `src/mlx_expert_sniper/server.py` | OpenAI-compatible HTTP server |
|
| 58 |
+
| `src/mlx_expert_sniper/profile.py` | Per-token profiling tools |
|
| 59 |
+
| `src/mlx_expert_sniper/cli.py` | `mlx-sniper` CLI entry point |
|
| 60 |
+
| `stream_preprocess.py` | Streaming preprocessor (downloads one shard at a time) |
|
| 61 |
+
|
| 62 |
+
## How It Relates to the Research
|
| 63 |
+
|
| 64 |
+
This is a packaged version of the same forward pass in `../qwen3_agent.py`:
|
| 65 |
+
- `cache.py` = extracted from `../expert_io.py`
|
| 66 |
+
- `sniper.py:forward_token()` = same loop as `Qwen3SniperEngine.forward_token()`
|
| 67 |
+
- `preprocess.py` = extracted from `../convert_qwen3_30b.py`
|
| 68 |
+
- `server.py` = extracted from `../sniper_server.py`
|
| 69 |
+
|
| 70 |
+
No algorithmic changes — same pread + F_NOCACHE + per-expert LRU + gather_qmm.
|
| 71 |
+
|
| 72 |
+
## Python API
|
| 73 |
+
|
| 74 |
+
```python
|
| 75 |
+
from mlx_expert_sniper import SniperEngine
|
| 76 |
+
|
| 77 |
+
engine = SniperEngine.from_dir("~/models/qwen3-30b")
|
| 78 |
+
|
| 79 |
+
for token in engine.generate("Write a haiku about AI"):
|
| 80 |
+
print(token, end="", flush=True)
|
| 81 |
+
```
|
pyproject.toml
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=68.0", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "mlx-expert-sniper"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "Run MoE models larger than RAM on Apple Silicon via expert sniping"
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
license = "Apache-2.0"
|
| 11 |
+
requires-python = ">=3.10"
|
| 12 |
+
authors = [
|
| 13 |
+
{name = "walter-grace"},
|
| 14 |
+
]
|
| 15 |
+
keywords = ["mlx", "moe", "apple-silicon", "expert-sniping", "inference"]
|
| 16 |
+
classifiers = [
|
| 17 |
+
"Development Status :: 4 - Beta",
|
| 18 |
+
"Intended Audience :: Developers",
|
| 19 |
+
"Operating System :: MacOS",
|
| 20 |
+
"Programming Language :: Python :: 3",
|
| 21 |
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
| 22 |
+
]
|
| 23 |
+
dependencies = [
|
| 24 |
+
"mlx>=0.22.0",
|
| 25 |
+
"mlx-lm>=0.22.0",
|
| 26 |
+
"transformers>=4.40.0",
|
| 27 |
+
"numpy",
|
| 28 |
+
"huggingface-hub",
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
[project.optional-dependencies]
|
| 32 |
+
dev = ["pytest", "rich"]
|
| 33 |
+
server = ["rich"]
|
| 34 |
+
|
| 35 |
+
[project.scripts]
|
| 36 |
+
mlx-sniper = "mlx_expert_sniper.cli:main"
|
| 37 |
+
|
| 38 |
+
[tool.setuptools.packages.find]
|
| 39 |
+
where = ["src"]
|
src/mlx_expert_sniper/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""mlx-expert-sniper: Run MoE models larger than RAM on Apple Silicon."""
|
| 2 |
+
|
| 3 |
+
from .config import SniperConfig
|
| 4 |
+
from .cache import PerExpertCache, ExpertReader
|
| 5 |
+
from .sniper import SniperEngine
|
| 6 |
+
from .generate import generate, stream_generate
|
| 7 |
+
from .preprocess import preprocess_model
|
| 8 |
+
|
| 9 |
+
__version__ = "0.1.0"
|
| 10 |
+
__all__ = [
|
| 11 |
+
"SniperConfig",
|
| 12 |
+
"PerExpertCache",
|
| 13 |
+
"ExpertReader",
|
| 14 |
+
"SniperEngine",
|
| 15 |
+
"generate",
|
| 16 |
+
"stream_generate",
|
| 17 |
+
"preprocess_model",
|
| 18 |
+
]
|
src/mlx_expert_sniper/cache.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Per-expert LRU cache + binary expert reader via pread + F_NOCACHE.
|
| 3 |
+
|
| 4 |
+
Proven: 88.5% hit rate with 2,000-expert LRU on Qwen3-30B-A3B.
|
| 5 |
+
Key insight: consecutive tokens share ~87.5% of experts (temporal locality),
|
| 6 |
+
so per-expert LRU is the correct eviction policy.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import json
|
| 11 |
+
import fcntl
|
| 12 |
+
import time
|
| 13 |
+
import numpy as np
|
| 14 |
+
from collections import OrderedDict
|
| 15 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 16 |
+
|
| 17 |
+
import mlx.core as mx
|
| 18 |
+
|
| 19 |
+
F_NOCACHE = 48 # macOS fcntl constant for bypassing page cache
|
| 20 |
+
PAGE_SIZE = 16384 # 16KB — matches DART IOMMU page size
|
| 21 |
+
|
| 22 |
+
MLX_DTYPES = {
|
| 23 |
+
"uint32": mx.uint32,
|
| 24 |
+
"float16": mx.float16,
|
| 25 |
+
"bfloat16": mx.float16, # bf16 converted to f16 in bin files
|
| 26 |
+
"float32": mx.float32,
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class PerExpertCache:
|
| 31 |
+
"""LRU cache keyed by (layer_idx, expert_id).
|
| 32 |
+
|
| 33 |
+
NOT per-expert-set (which gets 0% hit rate because 1 different
|
| 34 |
+
expert out of 8 causes a full miss). Per-expert caching gets
|
| 35 |
+
88.5% because 7 of 8 experts are reused between consecutive tokens.
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
def __init__(self, max_experts: int = 2000):
|
| 39 |
+
self.cache: OrderedDict = OrderedDict()
|
| 40 |
+
self.max_experts = max_experts
|
| 41 |
+
self.hits = 0
|
| 42 |
+
self.misses = 0
|
| 43 |
+
|
| 44 |
+
def get(self, layer_idx: int, expert_id: int):
|
| 45 |
+
"""Look up a single expert. Returns dict of tensors or None."""
|
| 46 |
+
key = (layer_idx, expert_id)
|
| 47 |
+
if key in self.cache:
|
| 48 |
+
self.hits += 1
|
| 49 |
+
self.cache.move_to_end(key)
|
| 50 |
+
return self.cache[key]
|
| 51 |
+
self.misses += 1
|
| 52 |
+
return None
|
| 53 |
+
|
| 54 |
+
def put(self, layer_idx: int, expert_id: int, data: dict):
|
| 55 |
+
"""Insert expert data, evicting LRU entries if full."""
|
| 56 |
+
key = (layer_idx, expert_id)
|
| 57 |
+
self.cache[key] = data
|
| 58 |
+
self.cache.move_to_end(key)
|
| 59 |
+
while len(self.cache) > self.max_experts:
|
| 60 |
+
self.cache.popitem(last=False)
|
| 61 |
+
|
| 62 |
+
@property
|
| 63 |
+
def hit_rate(self) -> float:
|
| 64 |
+
total = self.hits + self.misses
|
| 65 |
+
return self.hits / max(total, 1)
|
| 66 |
+
|
| 67 |
+
def stats(self) -> str:
|
| 68 |
+
total = self.hits + self.misses
|
| 69 |
+
rate = self.hits / max(total, 1) * 100
|
| 70 |
+
return (f"{rate:.0f}% hit ({self.hits}h/{self.misses}m), "
|
| 71 |
+
f"{len(self.cache)} cached")
|
| 72 |
+
|
| 73 |
+
def clear(self):
|
| 74 |
+
self.cache.clear()
|
| 75 |
+
self.hits = 0
|
| 76 |
+
self.misses = 0
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class ExpertReader:
|
| 80 |
+
"""Reads specific experts from binary layer files via pread + F_NOCACHE.
|
| 81 |
+
|
| 82 |
+
Binary format per layer file:
|
| 83 |
+
Header (PAGE_SIZE bytes): JSON with layout info
|
| 84 |
+
Data: expert_0 | expert_1 | ... | expert_N
|
| 85 |
+
Each expert: gate_proj.weight | gate_proj.scales | gate_proj.biases |
|
| 86 |
+
up_proj.weight | up_proj.scales | up_proj.biases |
|
| 87 |
+
down_proj.weight | down_proj.scales | down_proj.biases
|
| 88 |
+
"""
|
| 89 |
+
|
| 90 |
+
def __init__(self, bin_dir: str, num_workers: int = 4):
|
| 91 |
+
self.bin_dir = bin_dir
|
| 92 |
+
self.executor = ThreadPoolExecutor(max_workers=num_workers)
|
| 93 |
+
|
| 94 |
+
# Parse headers for all MoE layer files
|
| 95 |
+
self.headers = {}
|
| 96 |
+
self.fds = {}
|
| 97 |
+
for f in os.listdir(bin_dir):
|
| 98 |
+
if f.startswith("moe_layer_") and f.endswith(".bin"):
|
| 99 |
+
layer_idx = int(f.split("_")[2].split(".")[0])
|
| 100 |
+
path = os.path.join(bin_dir, f)
|
| 101 |
+
with open(path, "rb") as fh:
|
| 102 |
+
raw = fh.read(PAGE_SIZE)
|
| 103 |
+
self.headers[layer_idx] = json.loads(raw.rstrip(b"\x00"))
|
| 104 |
+
|
| 105 |
+
if not self.headers:
|
| 106 |
+
raise FileNotFoundError(f"No moe_layer_*.bin files in {bin_dir}")
|
| 107 |
+
|
| 108 |
+
# Layout from first layer (all layers share the same layout)
|
| 109 |
+
first = next(iter(self.headers.values()))["layout"]
|
| 110 |
+
self.expert_block_size = first["expert_block_size"]
|
| 111 |
+
self.data_start = first["data_start"]
|
| 112 |
+
self.tensor_layout = first["tensors"]
|
| 113 |
+
|
| 114 |
+
# Stats
|
| 115 |
+
self.read_time = 0.0
|
| 116 |
+
self.reads = 0
|
| 117 |
+
self.bytes_read = 0
|
| 118 |
+
|
| 119 |
+
@property
|
| 120 |
+
def num_layers(self) -> int:
|
| 121 |
+
return len(self.headers)
|
| 122 |
+
|
| 123 |
+
def _get_fd(self, layer_idx: int) -> int:
|
| 124 |
+
"""Get (or open) a file descriptor with F_NOCACHE for a layer."""
|
| 125 |
+
if layer_idx not in self.fds:
|
| 126 |
+
path = os.path.join(self.bin_dir, f"moe_layer_{layer_idx:02d}.bin")
|
| 127 |
+
fd = os.open(path, os.O_RDONLY)
|
| 128 |
+
fcntl.fcntl(fd, F_NOCACHE, 1) # Bypass macOS Unified Buffer Cache
|
| 129 |
+
self.fds[layer_idx] = fd
|
| 130 |
+
return self.fds[layer_idx]
|
| 131 |
+
|
| 132 |
+
def _read_expert(self, layer_idx: int, expert_id: int) -> bytes:
|
| 133 |
+
"""Read one expert's raw bytes via pread (no seek, thread-safe)."""
|
| 134 |
+
fd = self._get_fd(layer_idx)
|
| 135 |
+
offset = self.data_start + expert_id * self.expert_block_size
|
| 136 |
+
return os.pread(fd, self.expert_block_size, offset)
|
| 137 |
+
|
| 138 |
+
def _parse_expert(self, raw_bytes: bytes) -> dict:
|
| 139 |
+
"""Parse raw bytes into dict of MLX arrays."""
|
| 140 |
+
result = {}
|
| 141 |
+
for name, info in self.tensor_layout.items():
|
| 142 |
+
off = info["inner_offset"]
|
| 143 |
+
nbytes = info["nbytes"]
|
| 144 |
+
shape = info["shape_per_expert"]
|
| 145 |
+
dtype = MLX_DTYPES.get(info["dtype"], mx.float16)
|
| 146 |
+
|
| 147 |
+
arr_bytes = raw_bytes[off:off + nbytes]
|
| 148 |
+
if dtype == mx.uint32:
|
| 149 |
+
np_arr = np.frombuffer(arr_bytes, dtype=np.uint32).reshape(shape)
|
| 150 |
+
elif dtype == mx.float16:
|
| 151 |
+
np_arr = np.frombuffer(arr_bytes, dtype=np.float16).reshape(shape)
|
| 152 |
+
elif dtype == mx.float32:
|
| 153 |
+
np_arr = np.frombuffer(arr_bytes, dtype=np.float32).reshape(shape)
|
| 154 |
+
else:
|
| 155 |
+
np_arr = np.frombuffer(arr_bytes, dtype=np.float16).reshape(shape)
|
| 156 |
+
|
| 157 |
+
result[name] = mx.array(np_arr)
|
| 158 |
+
return result
|
| 159 |
+
|
| 160 |
+
def get_experts(self, layer_idx: int, expert_ids: list) -> dict:
|
| 161 |
+
"""Read and parse active experts for a layer (parallel pread).
|
| 162 |
+
|
| 163 |
+
Args:
|
| 164 |
+
layer_idx: MoE layer index
|
| 165 |
+
expert_ids: list of expert IDs to load
|
| 166 |
+
|
| 167 |
+
Returns:
|
| 168 |
+
dict mapping expert_id -> dict of tensor_name -> mx.array
|
| 169 |
+
"""
|
| 170 |
+
t0 = time.time()
|
| 171 |
+
|
| 172 |
+
futures = {
|
| 173 |
+
eid: self.executor.submit(self._read_expert, layer_idx, eid)
|
| 174 |
+
for eid in expert_ids
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
experts = {}
|
| 178 |
+
for eid, future in futures.items():
|
| 179 |
+
raw = future.result()
|
| 180 |
+
experts[eid] = self._parse_expert(raw)
|
| 181 |
+
self.bytes_read += len(raw)
|
| 182 |
+
|
| 183 |
+
self.read_time += time.time() - t0
|
| 184 |
+
self.reads += len(expert_ids)
|
| 185 |
+
return experts
|
| 186 |
+
|
| 187 |
+
def stats(self) -> str:
|
| 188 |
+
if self.reads == 0:
|
| 189 |
+
return "No reads"
|
| 190 |
+
avg_ms = self.read_time / self.reads * 1000
|
| 191 |
+
throughput = self.bytes_read / max(self.read_time, 0.001) / 1e9
|
| 192 |
+
return (f"reads={self.reads}, avg={avg_ms:.1f}ms/expert, "
|
| 193 |
+
f"throughput={throughput:.1f} GB/s, "
|
| 194 |
+
f"total={self.bytes_read / 1e9:.2f} GB")
|
| 195 |
+
|
| 196 |
+
def close(self):
|
| 197 |
+
for fd in self.fds.values():
|
| 198 |
+
os.close(fd)
|
| 199 |
+
self.executor.shutdown(wait=False)
|
| 200 |
+
self.fds.clear()
|
| 201 |
+
|
| 202 |
+
def __del__(self):
|
| 203 |
+
try:
|
| 204 |
+
self.close()
|
| 205 |
+
except Exception:
|
| 206 |
+
pass
|
src/mlx_expert_sniper/cli.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CLI for mlx-expert-sniper.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
mlx-sniper preprocess <model_dir> -o <output_dir>
|
| 6 |
+
mlx-sniper run <sniper_dir> --prompt "Hello" --max-tokens 100
|
| 7 |
+
mlx-sniper chat <sniper_dir>
|
| 8 |
+
mlx-sniper profile <sniper_dir> --tokens 50
|
| 9 |
+
mlx-sniper server <sniper_dir> --port 8899
|
| 10 |
+
mlx-sniper info <sniper_dir>
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import json
|
| 15 |
+
import sys
|
| 16 |
+
import time
|
| 17 |
+
import os
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def cmd_preprocess(args):
|
| 21 |
+
"""Preprocess a HuggingFace model into sniper format."""
|
| 22 |
+
from .preprocess import preprocess_model
|
| 23 |
+
|
| 24 |
+
print("=" * 50)
|
| 25 |
+
print(" mlx-expert-sniper: preprocess")
|
| 26 |
+
print("=" * 50)
|
| 27 |
+
|
| 28 |
+
result = preprocess_model(
|
| 29 |
+
model_dir=args.model_dir,
|
| 30 |
+
output_dir=args.output,
|
| 31 |
+
verbose=True,
|
| 32 |
+
)
|
| 33 |
+
print(f"\n Ready: {result['output_dir']}")
|
| 34 |
+
print(f" Run: mlx-sniper run {result['output_dir']}")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def cmd_run(args):
|
| 38 |
+
"""Generate text from a prompt."""
|
| 39 |
+
from .sniper import SniperEngine
|
| 40 |
+
|
| 41 |
+
print(" Loading...", end="", flush=True)
|
| 42 |
+
engine = SniperEngine.from_dir(
|
| 43 |
+
args.sniper_dir,
|
| 44 |
+
max_cached_experts=args.cache_size,
|
| 45 |
+
)
|
| 46 |
+
print(" ready.")
|
| 47 |
+
|
| 48 |
+
t0 = time.time()
|
| 49 |
+
tokens = 0
|
| 50 |
+
|
| 51 |
+
if args.stream:
|
| 52 |
+
for token in engine.generate(
|
| 53 |
+
args.prompt,
|
| 54 |
+
max_tokens=args.max_tokens,
|
| 55 |
+
temperature=args.temperature,
|
| 56 |
+
):
|
| 57 |
+
print(token, end="", flush=True)
|
| 58 |
+
tokens += 1
|
| 59 |
+
print()
|
| 60 |
+
else:
|
| 61 |
+
result = []
|
| 62 |
+
for token in engine.generate(
|
| 63 |
+
args.prompt,
|
| 64 |
+
max_tokens=args.max_tokens,
|
| 65 |
+
temperature=args.temperature,
|
| 66 |
+
):
|
| 67 |
+
result.append(token)
|
| 68 |
+
tokens += 1
|
| 69 |
+
print("".join(result))
|
| 70 |
+
|
| 71 |
+
elapsed = time.time() - t0
|
| 72 |
+
tps = tokens / elapsed if elapsed > 0 else 0
|
| 73 |
+
|
| 74 |
+
if args.verbose:
|
| 75 |
+
print(f"\n {tokens} tokens in {elapsed:.1f}s ({tps:.2f} tok/s)")
|
| 76 |
+
print(f" Cache: {engine.expert_cache.stats()}")
|
| 77 |
+
print(f" I/O: {engine.reader.stats()}")
|
| 78 |
+
|
| 79 |
+
engine.close()
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def cmd_chat(args):
|
| 83 |
+
"""Interactive chat session."""
|
| 84 |
+
from .sniper import SniperEngine
|
| 85 |
+
|
| 86 |
+
print("=" * 50)
|
| 87 |
+
print(" mlx-expert-sniper: chat")
|
| 88 |
+
print("=" * 50)
|
| 89 |
+
print(" Loading...", end="", flush=True)
|
| 90 |
+
|
| 91 |
+
engine = SniperEngine.from_dir(
|
| 92 |
+
args.sniper_dir,
|
| 93 |
+
max_cached_experts=args.cache_size,
|
| 94 |
+
)
|
| 95 |
+
print(" ready.")
|
| 96 |
+
print(f" Model: {engine.config.model_type}")
|
| 97 |
+
print(f" Cache: {engine.config.max_cached_experts} experts")
|
| 98 |
+
print(f" Commands: /stats, /clear, /quit")
|
| 99 |
+
print()
|
| 100 |
+
|
| 101 |
+
history = []
|
| 102 |
+
session_tokens = 0
|
| 103 |
+
session_time = 0.0
|
| 104 |
+
|
| 105 |
+
while True:
|
| 106 |
+
try:
|
| 107 |
+
user_input = input(" > ").strip()
|
| 108 |
+
except (EOFError, KeyboardInterrupt):
|
| 109 |
+
print("\n goodbye.")
|
| 110 |
+
break
|
| 111 |
+
|
| 112 |
+
if not user_input:
|
| 113 |
+
continue
|
| 114 |
+
|
| 115 |
+
if user_input in ("/quit", "/exit", "/q"):
|
| 116 |
+
break
|
| 117 |
+
|
| 118 |
+
if user_input == "/stats":
|
| 119 |
+
avg = session_tokens / session_time if session_time > 0 else 0
|
| 120 |
+
print(f" Tokens: {session_tokens:,}")
|
| 121 |
+
print(f" Time: {session_time:.1f}s")
|
| 122 |
+
print(f" Speed: {avg:.2f} tok/s")
|
| 123 |
+
print(f" Cache: {engine.expert_cache.stats()}")
|
| 124 |
+
print(f" I/O: {engine.reader.stats()}")
|
| 125 |
+
print()
|
| 126 |
+
continue
|
| 127 |
+
|
| 128 |
+
if user_input == "/clear":
|
| 129 |
+
history.clear()
|
| 130 |
+
engine.expert_cache.clear()
|
| 131 |
+
print(" (cleared)")
|
| 132 |
+
continue
|
| 133 |
+
|
| 134 |
+
history.append({"role": "user", "content": user_input})
|
| 135 |
+
messages = history[-20:] # Keep last 10 turns
|
| 136 |
+
|
| 137 |
+
t0 = time.time()
|
| 138 |
+
response = ""
|
| 139 |
+
token_count = 0
|
| 140 |
+
|
| 141 |
+
print(" ", end="", flush=True)
|
| 142 |
+
for token in engine.generate(
|
| 143 |
+
prompt=None,
|
| 144 |
+
chat_messages=messages,
|
| 145 |
+
max_tokens=args.max_tokens,
|
| 146 |
+
temperature=args.temperature,
|
| 147 |
+
):
|
| 148 |
+
print(token, end="", flush=True)
|
| 149 |
+
response += token
|
| 150 |
+
token_count += 1
|
| 151 |
+
print()
|
| 152 |
+
|
| 153 |
+
elapsed = time.time() - t0
|
| 154 |
+
tps = token_count / elapsed if elapsed > 0 else 0
|
| 155 |
+
session_tokens += token_count
|
| 156 |
+
session_time += elapsed
|
| 157 |
+
print(f" [{token_count} tokens, {elapsed:.1f}s, {tps:.2f} tok/s]")
|
| 158 |
+
print()
|
| 159 |
+
|
| 160 |
+
history.append({"role": "assistant", "content": response})
|
| 161 |
+
|
| 162 |
+
engine.close()
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def cmd_profile(args):
|
| 166 |
+
"""Profile inference performance."""
|
| 167 |
+
from .sniper import SniperEngine
|
| 168 |
+
from .profile import ProfileSession, TokenProfile
|
| 169 |
+
|
| 170 |
+
print(" Loading...", end="", flush=True)
|
| 171 |
+
engine = SniperEngine.from_dir(
|
| 172 |
+
args.sniper_dir,
|
| 173 |
+
max_cached_experts=args.cache_size,
|
| 174 |
+
)
|
| 175 |
+
print(" ready.")
|
| 176 |
+
|
| 177 |
+
prompt = args.prompt or "Explain the theory of relativity in detail."
|
| 178 |
+
session = ProfileSession()
|
| 179 |
+
|
| 180 |
+
print(f" Generating {args.tokens} tokens...")
|
| 181 |
+
print(f" Prompt: {prompt[:60]}...")
|
| 182 |
+
print()
|
| 183 |
+
|
| 184 |
+
t_prev = time.time()
|
| 185 |
+
token_idx = 0
|
| 186 |
+
prev_hits = 0
|
| 187 |
+
prev_misses = 0
|
| 188 |
+
|
| 189 |
+
for token in engine.generate(
|
| 190 |
+
prompt,
|
| 191 |
+
max_tokens=args.tokens,
|
| 192 |
+
temperature=0.0, # Deterministic for profiling
|
| 193 |
+
):
|
| 194 |
+
t_now = time.time()
|
| 195 |
+
cur_hits = engine.expert_cache.hits
|
| 196 |
+
cur_misses = engine.expert_cache.misses
|
| 197 |
+
|
| 198 |
+
profile = TokenProfile(
|
| 199 |
+
token_idx=token_idx,
|
| 200 |
+
total_ms=(t_now - t_prev) * 1000,
|
| 201 |
+
cache_hits=cur_hits - prev_hits,
|
| 202 |
+
cache_misses=cur_misses - prev_misses,
|
| 203 |
+
)
|
| 204 |
+
session.add(profile)
|
| 205 |
+
|
| 206 |
+
if token_idx % 20 == 0:
|
| 207 |
+
print(f" [{token_idx:3d}] {repr(token):20s} {profile.total_ms:.0f}ms "
|
| 208 |
+
f"hits={profile.cache_hits} miss={profile.cache_misses}", flush=True)
|
| 209 |
+
|
| 210 |
+
prev_hits = cur_hits
|
| 211 |
+
prev_misses = cur_misses
|
| 212 |
+
token_idx += 1
|
| 213 |
+
t_prev = t_now
|
| 214 |
+
|
| 215 |
+
print()
|
| 216 |
+
print(session.summary())
|
| 217 |
+
|
| 218 |
+
if args.csv:
|
| 219 |
+
session.to_csv(args.csv)
|
| 220 |
+
print(f"\n CSV: {args.csv}")
|
| 221 |
+
|
| 222 |
+
print(f"\n Cache: {engine.expert_cache.stats()}")
|
| 223 |
+
print(f" I/O: {engine.reader.stats()}")
|
| 224 |
+
engine.close()
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def cmd_server(args):
|
| 228 |
+
"""Start an OpenAI-compatible HTTP server."""
|
| 229 |
+
from .server import run_server
|
| 230 |
+
|
| 231 |
+
run_server(
|
| 232 |
+
sniper_dir=args.sniper_dir,
|
| 233 |
+
host=args.host,
|
| 234 |
+
port=args.port,
|
| 235 |
+
cache_size=args.cache_size,
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def cmd_info(args):
|
| 240 |
+
"""Show info about a sniper directory."""
|
| 241 |
+
from .config import SniperConfig
|
| 242 |
+
from pathlib import Path
|
| 243 |
+
import glob
|
| 244 |
+
|
| 245 |
+
cfg = SniperConfig.from_dir(args.sniper_dir)
|
| 246 |
+
|
| 247 |
+
pinned_size = os.path.getsize(cfg.pinned_path) / 1e9 if os.path.exists(cfg.pinned_path) else 0
|
| 248 |
+
bin_files = sorted(glob.glob(os.path.join(cfg.bin_dir, "moe_layer_*.bin")))
|
| 249 |
+
expert_total = sum(os.path.getsize(f) for f in bin_files) / 1e9
|
| 250 |
+
|
| 251 |
+
print(f" Directory: {cfg.sniper_dir}")
|
| 252 |
+
print(f" Model type: {cfg.model_type}")
|
| 253 |
+
print(f" Tokenizer: {cfg.tokenizer_name}")
|
| 254 |
+
print(f" Layers: {cfg.num_hidden_layers}")
|
| 255 |
+
print(f" Experts: {cfg.num_experts} per layer, top-{cfg.num_experts_per_tok}")
|
| 256 |
+
print(f" Hidden: {cfg.hidden_size}")
|
| 257 |
+
print(f" MoE inter: {cfg.moe_intermediate_size}")
|
| 258 |
+
print(f" Quant: {cfg.bits}-bit, group_size={cfg.group_size}")
|
| 259 |
+
print(f" Pinned: {pinned_size:.2f} GB")
|
| 260 |
+
print(f" Experts: {len(bin_files)} layers, {expert_total:.2f} GB")
|
| 261 |
+
print(f" Total: {pinned_size + expert_total:.2f} GB")
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def main():
|
| 265 |
+
parser = argparse.ArgumentParser(
|
| 266 |
+
prog="mlx-sniper",
|
| 267 |
+
description="Run MoE models larger than RAM on Apple Silicon",
|
| 268 |
+
)
|
| 269 |
+
sub = parser.add_subparsers(dest="command")
|
| 270 |
+
|
| 271 |
+
# preprocess
|
| 272 |
+
p = sub.add_parser("preprocess", help="Convert HF model to sniper format")
|
| 273 |
+
p.add_argument("model_dir", help="Path to HuggingFace model directory")
|
| 274 |
+
p.add_argument("-o", "--output", required=True, help="Output directory")
|
| 275 |
+
|
| 276 |
+
# run
|
| 277 |
+
p = sub.add_parser("run", help="Generate text from a prompt")
|
| 278 |
+
p.add_argument("sniper_dir", help="Path to sniper directory")
|
| 279 |
+
p.add_argument("--prompt", "-p", required=True, help="Text prompt")
|
| 280 |
+
p.add_argument("--max-tokens", type=int, default=200)
|
| 281 |
+
p.add_argument("--temperature", type=float, default=0.7)
|
| 282 |
+
p.add_argument("--stream", action="store_true", default=True)
|
| 283 |
+
p.add_argument("--no-stream", dest="stream", action="store_false")
|
| 284 |
+
p.add_argument("--cache-size", type=int, default=2000)
|
| 285 |
+
p.add_argument("--verbose", "-v", action="store_true")
|
| 286 |
+
|
| 287 |
+
# chat
|
| 288 |
+
p = sub.add_parser("chat", help="Interactive chat")
|
| 289 |
+
p.add_argument("sniper_dir", help="Path to sniper directory")
|
| 290 |
+
p.add_argument("--max-tokens", type=int, default=500)
|
| 291 |
+
p.add_argument("--temperature", type=float, default=0.7)
|
| 292 |
+
p.add_argument("--cache-size", type=int, default=2000)
|
| 293 |
+
|
| 294 |
+
# profile
|
| 295 |
+
p = sub.add_parser("profile", help="Profile inference performance")
|
| 296 |
+
p.add_argument("sniper_dir", help="Path to sniper directory")
|
| 297 |
+
p.add_argument("--tokens", type=int, default=50)
|
| 298 |
+
p.add_argument("--prompt", default=None)
|
| 299 |
+
p.add_argument("--csv", default=None, help="Output CSV path")
|
| 300 |
+
p.add_argument("--cache-size", type=int, default=2000)
|
| 301 |
+
|
| 302 |
+
# server
|
| 303 |
+
p = sub.add_parser("server", help="OpenAI-compatible HTTP server")
|
| 304 |
+
p.add_argument("sniper_dir", help="Path to sniper directory")
|
| 305 |
+
p.add_argument("--host", default="0.0.0.0")
|
| 306 |
+
p.add_argument("--port", type=int, default=8899)
|
| 307 |
+
p.add_argument("--cache-size", type=int, default=2000)
|
| 308 |
+
|
| 309 |
+
# info
|
| 310 |
+
p = sub.add_parser("info", help="Show sniper directory info")
|
| 311 |
+
p.add_argument("sniper_dir", help="Path to sniper directory")
|
| 312 |
+
|
| 313 |
+
args = parser.parse_args()
|
| 314 |
+
|
| 315 |
+
if args.command is None:
|
| 316 |
+
parser.print_help()
|
| 317 |
+
sys.exit(1)
|
| 318 |
+
|
| 319 |
+
commands = {
|
| 320 |
+
"preprocess": cmd_preprocess,
|
| 321 |
+
"run": cmd_run,
|
| 322 |
+
"chat": cmd_chat,
|
| 323 |
+
"profile": cmd_profile,
|
| 324 |
+
"server": cmd_server,
|
| 325 |
+
"info": cmd_info,
|
| 326 |
+
}
|
| 327 |
+
commands[args.command](args)
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
if __name__ == "__main__":
|
| 331 |
+
main()
|
src/mlx_expert_sniper/config.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration for the expert sniper."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Optional
|
| 6 |
+
import json
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class SniperConfig:
|
| 11 |
+
"""Configuration for expert sniping inference."""
|
| 12 |
+
|
| 13 |
+
# Model paths
|
| 14 |
+
sniper_dir: str = "" # Directory with pinned.safetensors + bin/
|
| 15 |
+
|
| 16 |
+
# Cache settings
|
| 17 |
+
max_cached_experts: int = 2000 # LRU cache capacity
|
| 18 |
+
num_io_workers: int = 4 # Parallel pread threads
|
| 19 |
+
|
| 20 |
+
# Quantization (read from config.json)
|
| 21 |
+
bits: int = 4
|
| 22 |
+
group_size: int = 64
|
| 23 |
+
|
| 24 |
+
# MLX memory limits
|
| 25 |
+
memory_limit_gb: float = 12.0
|
| 26 |
+
cache_limit_mb: int = 256
|
| 27 |
+
|
| 28 |
+
# Generation defaults
|
| 29 |
+
max_tokens: int = 500
|
| 30 |
+
temperature: float = 0.7
|
| 31 |
+
repetition_penalty: float = 1.0
|
| 32 |
+
|
| 33 |
+
# Model architecture (populated from config.json)
|
| 34 |
+
num_hidden_layers: int = 0
|
| 35 |
+
num_experts: int = 0
|
| 36 |
+
num_experts_per_tok: int = 0
|
| 37 |
+
hidden_size: int = 0
|
| 38 |
+
moe_intermediate_size: int = 0
|
| 39 |
+
vocab_size: int = 0
|
| 40 |
+
norm_topk_prob: bool = True
|
| 41 |
+
model_type: str = ""
|
| 42 |
+
tokenizer_name: str = ""
|
| 43 |
+
|
| 44 |
+
@classmethod
|
| 45 |
+
def from_dir(cls, sniper_dir: str, **overrides) -> "SniperConfig":
|
| 46 |
+
"""Load config from a sniper directory (must have config.json)."""
|
| 47 |
+
sniper_dir = str(Path(sniper_dir).expanduser().resolve())
|
| 48 |
+
config_path = Path(sniper_dir) / "config.json"
|
| 49 |
+
if not config_path.exists():
|
| 50 |
+
raise FileNotFoundError(f"No config.json in {sniper_dir}")
|
| 51 |
+
|
| 52 |
+
with open(config_path) as f:
|
| 53 |
+
model_config = json.load(f)
|
| 54 |
+
|
| 55 |
+
quant = model_config.get("quantization", {})
|
| 56 |
+
|
| 57 |
+
cfg = cls(
|
| 58 |
+
sniper_dir=sniper_dir,
|
| 59 |
+
bits=quant.get("bits", 4),
|
| 60 |
+
group_size=quant.get("group_size", 64),
|
| 61 |
+
num_hidden_layers=model_config.get("num_hidden_layers", 0),
|
| 62 |
+
num_experts=model_config.get("num_experts", 0),
|
| 63 |
+
num_experts_per_tok=model_config.get("num_experts_per_tok", 0),
|
| 64 |
+
hidden_size=model_config.get("hidden_size", 0),
|
| 65 |
+
moe_intermediate_size=model_config.get("moe_intermediate_size", 0),
|
| 66 |
+
vocab_size=model_config.get("vocab_size", 0),
|
| 67 |
+
norm_topk_prob=model_config.get("norm_topk_prob", True),
|
| 68 |
+
model_type=model_config.get("model_type", ""),
|
| 69 |
+
tokenizer_name=model_config.get("_name_or_path", ""),
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
for k, v in overrides.items():
|
| 73 |
+
if hasattr(cfg, k):
|
| 74 |
+
setattr(cfg, k, v)
|
| 75 |
+
|
| 76 |
+
return cfg
|
| 77 |
+
|
| 78 |
+
@property
|
| 79 |
+
def pinned_path(self) -> str:
|
| 80 |
+
return str(Path(self.sniper_dir) / "pinned.safetensors")
|
| 81 |
+
|
| 82 |
+
@property
|
| 83 |
+
def bin_dir(self) -> str:
|
| 84 |
+
return str(Path(self.sniper_dir) / "bin")
|
| 85 |
+
|
| 86 |
+
@property
|
| 87 |
+
def config_json_path(self) -> str:
|
| 88 |
+
return str(Path(self.sniper_dir) / "config.json")
|
src/mlx_expert_sniper/generate.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
High-level generation functions.
|
| 3 |
+
|
| 4 |
+
These wrap SniperEngine for simple one-shot usage.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import time
|
| 8 |
+
from typing import Optional
|
| 9 |
+
|
| 10 |
+
from .config import SniperConfig
|
| 11 |
+
from .sniper import SniperEngine
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def generate(
|
| 15 |
+
sniper_dir: str,
|
| 16 |
+
prompt: str,
|
| 17 |
+
max_tokens: int = 200,
|
| 18 |
+
temperature: float = 0.7,
|
| 19 |
+
verbose: bool = False,
|
| 20 |
+
**config_overrides,
|
| 21 |
+
) -> str:
|
| 22 |
+
"""Generate text from a prompt. Returns the full response.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
sniper_dir: Path to preprocessed sniper directory
|
| 26 |
+
prompt: Text prompt
|
| 27 |
+
max_tokens: Maximum tokens to generate
|
| 28 |
+
temperature: Sampling temperature (0 = greedy)
|
| 29 |
+
verbose: Print timing info
|
| 30 |
+
**config_overrides: Override SniperConfig fields
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
Generated text as a string
|
| 34 |
+
"""
|
| 35 |
+
engine = SniperEngine.from_dir(sniper_dir, **config_overrides)
|
| 36 |
+
try:
|
| 37 |
+
result = []
|
| 38 |
+
t0 = time.time()
|
| 39 |
+
for token in engine.generate(prompt, max_tokens=max_tokens,
|
| 40 |
+
temperature=temperature):
|
| 41 |
+
result.append(token)
|
| 42 |
+
elapsed = time.time() - t0
|
| 43 |
+
|
| 44 |
+
if verbose:
|
| 45 |
+
n = len(result)
|
| 46 |
+
tps = n / elapsed if elapsed > 0 else 0
|
| 47 |
+
print(f"\n[{n} tokens in {elapsed:.1f}s — {tps:.2f} tok/s]")
|
| 48 |
+
print(f"[Cache: {engine.expert_cache.stats()}]")
|
| 49 |
+
print(f"[I/O: {engine.reader.stats()}]")
|
| 50 |
+
|
| 51 |
+
return "".join(result)
|
| 52 |
+
finally:
|
| 53 |
+
engine.close()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def stream_generate(
|
| 57 |
+
sniper_dir: str,
|
| 58 |
+
prompt: str,
|
| 59 |
+
max_tokens: int = 200,
|
| 60 |
+
temperature: float = 0.7,
|
| 61 |
+
**config_overrides,
|
| 62 |
+
):
|
| 63 |
+
"""Stream tokens from a prompt. Yields each token as it's generated.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
sniper_dir: Path to preprocessed sniper directory
|
| 67 |
+
prompt: Text prompt
|
| 68 |
+
max_tokens: Maximum tokens to generate
|
| 69 |
+
temperature: Sampling temperature (0 = greedy)
|
| 70 |
+
**config_overrides: Override SniperConfig fields
|
| 71 |
+
|
| 72 |
+
Yields:
|
| 73 |
+
str: Each generated token
|
| 74 |
+
"""
|
| 75 |
+
engine = SniperEngine.from_dir(sniper_dir, **config_overrides)
|
| 76 |
+
try:
|
| 77 |
+
yield from engine.generate(prompt, max_tokens=max_tokens,
|
| 78 |
+
temperature=temperature)
|
| 79 |
+
finally:
|
| 80 |
+
engine.close()
|
src/mlx_expert_sniper/preprocess.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Preprocess a HuggingFace MoE model into sniper format.
|
| 3 |
+
|
| 4 |
+
Converts from:
|
| 5 |
+
~/.cache/huggingface/hub/models--<repo>/snapshots/<hash>/
|
| 6 |
+
model-00001-of-00007.safetensors
|
| 7 |
+
...
|
| 8 |
+
config.json, tokenizer.json, etc.
|
| 9 |
+
|
| 10 |
+
To:
|
| 11 |
+
<output_dir>/
|
| 12 |
+
config.json
|
| 13 |
+
tokenizer.json
|
| 14 |
+
tokenizer_config.json
|
| 15 |
+
pinned.safetensors (~0.87 GB for Qwen3-30B)
|
| 16 |
+
bin/
|
| 17 |
+
moe_layer_00.bin (~340 MB per layer)
|
| 18 |
+
moe_layer_01.bin
|
| 19 |
+
...
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import os
|
| 23 |
+
import sys
|
| 24 |
+
import json
|
| 25 |
+
import time
|
| 26 |
+
import gc
|
| 27 |
+
import glob
|
| 28 |
+
import shutil
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
import mlx.core as mx
|
| 33 |
+
|
| 34 |
+
PAGE_SIZE = 16384
|
| 35 |
+
|
| 36 |
+
TENSOR_NAMES = [
|
| 37 |
+
"gate_proj.weight", "gate_proj.scales", "gate_proj.biases",
|
| 38 |
+
"up_proj.weight", "up_proj.scales", "up_proj.biases",
|
| 39 |
+
"down_proj.weight", "down_proj.scales", "down_proj.biases",
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _convert_layer_to_bin(layer_data, layer_idx, num_experts, output_dir):
|
| 44 |
+
"""Convert one layer's expert tensors to binary format."""
|
| 45 |
+
tensor_info = {}
|
| 46 |
+
expert_block_size = 0
|
| 47 |
+
|
| 48 |
+
for name in TENSOR_NAMES:
|
| 49 |
+
t = layer_data[name]
|
| 50 |
+
per_expert_shape = list(t.shape[1:])
|
| 51 |
+
if t.dtype == mx.uint32:
|
| 52 |
+
elem_size = 4
|
| 53 |
+
elif t.dtype in (mx.bfloat16, mx.float16):
|
| 54 |
+
elem_size = 2
|
| 55 |
+
else:
|
| 56 |
+
elem_size = 4
|
| 57 |
+
nbytes = 1
|
| 58 |
+
for s in per_expert_shape:
|
| 59 |
+
nbytes *= s
|
| 60 |
+
nbytes *= elem_size
|
| 61 |
+
tensor_info[name] = {
|
| 62 |
+
"shape_per_expert": per_expert_shape,
|
| 63 |
+
"dtype": str(t.dtype).replace("mlx.core.", ""),
|
| 64 |
+
"nbytes": nbytes,
|
| 65 |
+
"inner_offset": expert_block_size,
|
| 66 |
+
}
|
| 67 |
+
expert_block_size += nbytes
|
| 68 |
+
|
| 69 |
+
header = {
|
| 70 |
+
"layer_idx": layer_idx,
|
| 71 |
+
"num_experts": num_experts,
|
| 72 |
+
"layout": {
|
| 73 |
+
"expert_block_size": expert_block_size,
|
| 74 |
+
"data_start": PAGE_SIZE,
|
| 75 |
+
"tensors": tensor_info,
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
header_bytes = json.dumps(header, indent=2).encode()
|
| 79 |
+
assert len(header_bytes) < PAGE_SIZE
|
| 80 |
+
header_bytes += b"\x00" * (PAGE_SIZE - len(header_bytes))
|
| 81 |
+
|
| 82 |
+
out_path = os.path.join(output_dir, "bin", f"moe_layer_{layer_idx:02d}.bin")
|
| 83 |
+
with open(out_path, "wb") as f:
|
| 84 |
+
f.write(header_bytes)
|
| 85 |
+
for expert_id in range(num_experts):
|
| 86 |
+
for name in TENSOR_NAMES:
|
| 87 |
+
t = layer_data[name][expert_id]
|
| 88 |
+
if t.dtype == mx.bfloat16:
|
| 89 |
+
raw = np.array(t.astype(mx.float16)).astype(np.float16).tobytes()
|
| 90 |
+
elif t.dtype == mx.uint32:
|
| 91 |
+
raw = np.array(t).astype(np.uint32).tobytes()
|
| 92 |
+
else:
|
| 93 |
+
raw = np.array(t).tobytes()
|
| 94 |
+
f.write(raw)
|
| 95 |
+
|
| 96 |
+
return os.path.getsize(out_path), expert_block_size
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def preprocess_model(model_dir: str, output_dir: str, verbose: bool = True):
|
| 100 |
+
"""Convert a HuggingFace MoE model to sniper binary format.
|
| 101 |
+
|
| 102 |
+
Args:
|
| 103 |
+
model_dir: Path to HuggingFace model directory (with safetensors shards)
|
| 104 |
+
output_dir: Where to write the sniper format
|
| 105 |
+
verbose: Print progress
|
| 106 |
+
"""
|
| 107 |
+
model_dir = str(Path(model_dir).expanduser().resolve())
|
| 108 |
+
output_dir = str(Path(output_dir).expanduser().resolve())
|
| 109 |
+
|
| 110 |
+
# Find model directory (could be a HF cache path or direct path)
|
| 111 |
+
if not os.path.exists(os.path.join(model_dir, "config.json")):
|
| 112 |
+
# Try HF cache format
|
| 113 |
+
snap_dirs = glob.glob(os.path.join(model_dir, "snapshots", "*/"))
|
| 114 |
+
if snap_dirs:
|
| 115 |
+
model_dir = snap_dirs[0]
|
| 116 |
+
|
| 117 |
+
config_path = os.path.join(model_dir, "config.json")
|
| 118 |
+
if not os.path.exists(config_path):
|
| 119 |
+
raise FileNotFoundError(f"No config.json in {model_dir}")
|
| 120 |
+
|
| 121 |
+
os.makedirs(os.path.join(output_dir, "bin"), exist_ok=True)
|
| 122 |
+
|
| 123 |
+
# Copy config + tokenizer files
|
| 124 |
+
for fname in ["config.json", "tokenizer.json", "tokenizer_config.json",
|
| 125 |
+
"special_tokens_map.json", "vocab.json", "merges.txt"]:
|
| 126 |
+
src = os.path.join(model_dir, fname)
|
| 127 |
+
if os.path.exists(src):
|
| 128 |
+
shutil.copy(src, os.path.join(output_dir, fname))
|
| 129 |
+
|
| 130 |
+
# Find shard index
|
| 131 |
+
index_path = os.path.join(model_dir, "model.safetensors.index.json")
|
| 132 |
+
if os.path.exists(index_path):
|
| 133 |
+
with open(index_path) as f:
|
| 134 |
+
idx = json.load(f)
|
| 135 |
+
shards = sorted(set(idx["weight_map"].values()))
|
| 136 |
+
else:
|
| 137 |
+
# Single shard
|
| 138 |
+
shards = [f for f in os.listdir(model_dir) if f.endswith(".safetensors")]
|
| 139 |
+
|
| 140 |
+
if verbose:
|
| 141 |
+
print(f" Source: {model_dir}")
|
| 142 |
+
print(f" Output: {output_dir}")
|
| 143 |
+
print(f" Shards: {len(shards)}")
|
| 144 |
+
|
| 145 |
+
pinned = {}
|
| 146 |
+
layers_done = set()
|
| 147 |
+
|
| 148 |
+
with open(os.path.join(output_dir, "config.json")) as f:
|
| 149 |
+
config = json.load(f)
|
| 150 |
+
num_layers = config.get("num_hidden_layers", 48)
|
| 151 |
+
|
| 152 |
+
for si, shard_name in enumerate(shards):
|
| 153 |
+
if verbose:
|
| 154 |
+
print(f"\n [{si + 1}/{len(shards)}] {shard_name}...")
|
| 155 |
+
|
| 156 |
+
t0 = time.time()
|
| 157 |
+
data = mx.load(os.path.join(model_dir, shard_name))
|
| 158 |
+
|
| 159 |
+
# Classify tensors as expert vs pinned
|
| 160 |
+
layer_experts = {}
|
| 161 |
+
for key, tensor in data.items():
|
| 162 |
+
if "switch_mlp" in key:
|
| 163 |
+
layer = int(key.split(".layers.")[1].split(".")[0])
|
| 164 |
+
short = key.split(".switch_mlp.")[1]
|
| 165 |
+
layer_experts.setdefault(layer, {})[short] = tensor
|
| 166 |
+
else:
|
| 167 |
+
pinned[key] = tensor
|
| 168 |
+
|
| 169 |
+
# Convert complete expert layers
|
| 170 |
+
for layer_idx, tensors in layer_experts.items():
|
| 171 |
+
if len(tensors) < 9: # Partial layer (spans shards)
|
| 172 |
+
continue
|
| 173 |
+
if layer_idx in layers_done:
|
| 174 |
+
continue
|
| 175 |
+
|
| 176 |
+
num_experts = tensors[list(tensors.keys())[0]].shape[0]
|
| 177 |
+
sz, ebs = _convert_layer_to_bin(tensors, layer_idx, num_experts,
|
| 178 |
+
output_dir)
|
| 179 |
+
layers_done.add(layer_idx)
|
| 180 |
+
if verbose:
|
| 181 |
+
print(f" Layer {layer_idx}: {sz / 1e6:.0f} MB "
|
| 182 |
+
f"({ebs / 1e6:.2f} MB/expert)")
|
| 183 |
+
|
| 184 |
+
del data, layer_experts
|
| 185 |
+
gc.collect()
|
| 186 |
+
mx.clear_cache()
|
| 187 |
+
if verbose:
|
| 188 |
+
print(f" {time.time() - t0:.1f}s")
|
| 189 |
+
|
| 190 |
+
# Handle partial layers (spanning multiple shards)
|
| 191 |
+
incomplete = set(range(num_layers)) - layers_done
|
| 192 |
+
if incomplete:
|
| 193 |
+
if verbose:
|
| 194 |
+
print(f"\n Merging {len(incomplete)} partial layers...")
|
| 195 |
+
for layer_idx in sorted(incomplete):
|
| 196 |
+
tensors = {}
|
| 197 |
+
for shard_name in shards:
|
| 198 |
+
data = mx.load(os.path.join(model_dir, shard_name))
|
| 199 |
+
for key, tensor in data.items():
|
| 200 |
+
if f".layers.{layer_idx}." in key and "switch_mlp" in key:
|
| 201 |
+
short = key.split(".switch_mlp.")[1]
|
| 202 |
+
tensors[short] = tensor
|
| 203 |
+
del data
|
| 204 |
+
if len(tensors) >= 9:
|
| 205 |
+
num_experts = tensors[list(tensors.keys())[0]].shape[0]
|
| 206 |
+
sz, ebs = _convert_layer_to_bin(tensors, layer_idx, num_experts,
|
| 207 |
+
output_dir)
|
| 208 |
+
layers_done.add(layer_idx)
|
| 209 |
+
if verbose:
|
| 210 |
+
print(f" Layer {layer_idx}: {sz / 1e6:.0f} MB (merged)")
|
| 211 |
+
|
| 212 |
+
# Save pinned weights
|
| 213 |
+
if verbose:
|
| 214 |
+
print(f"\n Saving pinned weights ({len(pinned)} tensors)...")
|
| 215 |
+
pinned_path = os.path.join(output_dir, "pinned.safetensors")
|
| 216 |
+
mx.save_safetensors(pinned_path, pinned)
|
| 217 |
+
psz = os.path.getsize(pinned_path) / 1e9
|
| 218 |
+
del pinned
|
| 219 |
+
|
| 220 |
+
# Summary
|
| 221 |
+
bin_files = sorted(glob.glob(os.path.join(output_dir, "bin", "moe_layer_*.bin")))
|
| 222 |
+
total = sum(os.path.getsize(f) for f in bin_files)
|
| 223 |
+
if verbose:
|
| 224 |
+
print(f"\n Expert layers: {len(bin_files)}/{num_layers}")
|
| 225 |
+
print(f" Expert total: {total / 1e9:.2f} GB")
|
| 226 |
+
print(f" Pinned: {psz:.2f} GB")
|
| 227 |
+
print(f" Done!")
|
| 228 |
+
|
| 229 |
+
return {
|
| 230 |
+
"expert_layers": len(bin_files),
|
| 231 |
+
"expert_total_gb": total / 1e9,
|
| 232 |
+
"pinned_gb": psz,
|
| 233 |
+
"output_dir": output_dir,
|
| 234 |
+
}
|
src/mlx_expert_sniper/profile.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Profiling tools for expert sniping inference.
|
| 3 |
+
|
| 4 |
+
Measures per-token breakdown: attention, router, I/O, compute, cache stats.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import time
|
| 8 |
+
from dataclasses import dataclass, field
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@dataclass
|
| 12 |
+
class TokenProfile:
|
| 13 |
+
"""Timing breakdown for a single token."""
|
| 14 |
+
token_idx: int = 0
|
| 15 |
+
attention_ms: float = 0.0
|
| 16 |
+
router_ms: float = 0.0
|
| 17 |
+
io_ms: float = 0.0
|
| 18 |
+
compute_ms: float = 0.0
|
| 19 |
+
total_ms: float = 0.0
|
| 20 |
+
cache_hits: int = 0
|
| 21 |
+
cache_misses: int = 0
|
| 22 |
+
experts_loaded: int = 0
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class ProfileSession:
|
| 27 |
+
"""Accumulated profiling data across multiple tokens."""
|
| 28 |
+
tokens: list = field(default_factory=list)
|
| 29 |
+
|
| 30 |
+
def add(self, profile: TokenProfile):
|
| 31 |
+
self.tokens.append(profile)
|
| 32 |
+
|
| 33 |
+
@property
|
| 34 |
+
def num_tokens(self) -> int:
|
| 35 |
+
return len(self.tokens)
|
| 36 |
+
|
| 37 |
+
@property
|
| 38 |
+
def avg_tok_per_sec(self) -> float:
|
| 39 |
+
if not self.tokens:
|
| 40 |
+
return 0.0
|
| 41 |
+
total_ms = sum(t.total_ms for t in self.tokens)
|
| 42 |
+
return len(self.tokens) / (total_ms / 1000) if total_ms > 0 else 0
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def total_cache_hits(self) -> int:
|
| 46 |
+
return sum(t.cache_hits for t in self.tokens)
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def total_cache_misses(self) -> int:
|
| 50 |
+
return sum(t.cache_misses for t in self.tokens)
|
| 51 |
+
|
| 52 |
+
@property
|
| 53 |
+
def cache_hit_rate(self) -> float:
|
| 54 |
+
total = self.total_cache_hits + self.total_cache_misses
|
| 55 |
+
return self.total_cache_hits / max(total, 1)
|
| 56 |
+
|
| 57 |
+
def summary(self) -> str:
|
| 58 |
+
if not self.tokens:
|
| 59 |
+
return "No tokens profiled"
|
| 60 |
+
|
| 61 |
+
n = len(self.tokens)
|
| 62 |
+
total_ms = sum(t.total_ms for t in self.tokens)
|
| 63 |
+
avg_ms = total_ms / n
|
| 64 |
+
tps = n / (total_ms / 1000) if total_ms > 0 else 0
|
| 65 |
+
|
| 66 |
+
# Skip first token (prefill) for steady-state stats
|
| 67 |
+
steady = self.tokens[1:] if n > 1 else self.tokens
|
| 68 |
+
if steady:
|
| 69 |
+
ss_ms = sum(t.total_ms for t in steady) / len(steady)
|
| 70 |
+
ss_tps = len(steady) / (sum(t.total_ms for t in steady) / 1000)
|
| 71 |
+
else:
|
| 72 |
+
ss_ms = avg_ms
|
| 73 |
+
ss_tps = tps
|
| 74 |
+
|
| 75 |
+
lines = [
|
| 76 |
+
f" Tokens: {n}",
|
| 77 |
+
f" Total: {total_ms / 1000:.2f}s",
|
| 78 |
+
f" Avg: {avg_ms:.1f} ms/token ({tps:.2f} tok/s)",
|
| 79 |
+
f" Steady: {ss_ms:.1f} ms/token ({ss_tps:.2f} tok/s)",
|
| 80 |
+
f" Cache: {self.cache_hit_rate * 100:.1f}% hit rate "
|
| 81 |
+
f"({self.total_cache_hits}h/{self.total_cache_misses}m)",
|
| 82 |
+
]
|
| 83 |
+
return "\n".join(lines)
|
| 84 |
+
|
| 85 |
+
def to_csv(self, path: str):
|
| 86 |
+
"""Write per-token profiles to CSV."""
|
| 87 |
+
with open(path, "w") as f:
|
| 88 |
+
f.write("token,total_ms,attention_ms,router_ms,io_ms,compute_ms,"
|
| 89 |
+
"cache_hits,cache_misses\n")
|
| 90 |
+
for t in self.tokens:
|
| 91 |
+
f.write(f"{t.token_idx},{t.total_ms:.2f},{t.attention_ms:.2f},"
|
| 92 |
+
f"{t.router_ms:.2f},{t.io_ms:.2f},{t.compute_ms:.2f},"
|
| 93 |
+
f"{t.cache_hits},{t.cache_misses}\n")
|
src/mlx_expert_sniper/server.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
OpenAI-compatible HTTP server wrapping the sniper engine.
|
| 3 |
+
|
| 4 |
+
Endpoints:
|
| 5 |
+
GET /v1/models — list available models
|
| 6 |
+
POST /v1/chat/completions — chat (streaming + non-streaming)
|
| 7 |
+
GET /health — health check
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import time
|
| 12 |
+
import uuid
|
| 13 |
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
| 14 |
+
|
| 15 |
+
from .sniper import SniperEngine
|
| 16 |
+
|
| 17 |
+
_engine = None
|
| 18 |
+
_model_name = "mlx-expert-sniper"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class _Handler(BaseHTTPRequestHandler):
|
| 22 |
+
def log_message(self, format, *args):
|
| 23 |
+
pass
|
| 24 |
+
|
| 25 |
+
def _send_json(self, data, status=200):
|
| 26 |
+
body = json.dumps(data).encode()
|
| 27 |
+
self.send_response(status)
|
| 28 |
+
self.send_header("Content-Type", "application/json")
|
| 29 |
+
self.send_header("Content-Length", len(body))
|
| 30 |
+
self.send_header("Access-Control-Allow-Origin", "*")
|
| 31 |
+
self.end_headers()
|
| 32 |
+
self.wfile.write(body)
|
| 33 |
+
|
| 34 |
+
def _send_sse(self, data):
|
| 35 |
+
self.wfile.write(f"data: {json.dumps(data)}\n\n".encode())
|
| 36 |
+
self.wfile.flush()
|
| 37 |
+
|
| 38 |
+
def do_OPTIONS(self):
|
| 39 |
+
self.send_response(200)
|
| 40 |
+
self.send_header("Access-Control-Allow-Origin", "*")
|
| 41 |
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
| 42 |
+
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
| 43 |
+
self.end_headers()
|
| 44 |
+
|
| 45 |
+
def do_GET(self):
|
| 46 |
+
if self.path == "/health":
|
| 47 |
+
self._send_json({"status": "ok", "model": _model_name})
|
| 48 |
+
elif self.path == "/v1/models":
|
| 49 |
+
self._send_json({
|
| 50 |
+
"object": "list",
|
| 51 |
+
"data": [{
|
| 52 |
+
"id": _model_name,
|
| 53 |
+
"object": "model",
|
| 54 |
+
"created": int(time.time()),
|
| 55 |
+
"owned_by": "mlx-expert-sniper",
|
| 56 |
+
}]
|
| 57 |
+
})
|
| 58 |
+
else:
|
| 59 |
+
self._send_json({"error": "not found"}, 404)
|
| 60 |
+
|
| 61 |
+
def do_POST(self):
|
| 62 |
+
if self.path != "/v1/chat/completions":
|
| 63 |
+
self._send_json({"error": "not found"}, 404)
|
| 64 |
+
return
|
| 65 |
+
|
| 66 |
+
content_length = int(self.headers.get("Content-Length", 0))
|
| 67 |
+
body = json.loads(self.rfile.read(content_length))
|
| 68 |
+
|
| 69 |
+
messages = body.get("messages", [])
|
| 70 |
+
max_tokens = body.get("max_tokens", 500)
|
| 71 |
+
temperature = body.get("temperature", 0.7)
|
| 72 |
+
stream = body.get("stream", False)
|
| 73 |
+
request_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
|
| 74 |
+
|
| 75 |
+
if stream:
|
| 76 |
+
self.send_response(200)
|
| 77 |
+
self.send_header("Content-Type", "text/event-stream")
|
| 78 |
+
self.send_header("Cache-Control", "no-cache")
|
| 79 |
+
self.send_header("Access-Control-Allow-Origin", "*")
|
| 80 |
+
self.end_headers()
|
| 81 |
+
|
| 82 |
+
self._send_sse({
|
| 83 |
+
"id": request_id, "object": "chat.completion.chunk",
|
| 84 |
+
"model": _model_name, "created": int(time.time()),
|
| 85 |
+
"choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}]
|
| 86 |
+
})
|
| 87 |
+
|
| 88 |
+
total_tokens = 0
|
| 89 |
+
for token_text in _engine.generate(
|
| 90 |
+
prompt=None, chat_messages=messages,
|
| 91 |
+
max_tokens=max_tokens, temperature=temperature,
|
| 92 |
+
):
|
| 93 |
+
total_tokens += 1
|
| 94 |
+
self._send_sse({
|
| 95 |
+
"id": request_id, "object": "chat.completion.chunk",
|
| 96 |
+
"model": _model_name, "created": int(time.time()),
|
| 97 |
+
"choices": [{"index": 0, "delta": {"content": token_text}, "finish_reason": None}]
|
| 98 |
+
})
|
| 99 |
+
|
| 100 |
+
self._send_sse({
|
| 101 |
+
"id": request_id, "object": "chat.completion.chunk",
|
| 102 |
+
"model": _model_name, "created": int(time.time()),
|
| 103 |
+
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]
|
| 104 |
+
})
|
| 105 |
+
self.wfile.write(b"data: [DONE]\n\n")
|
| 106 |
+
self.wfile.flush()
|
| 107 |
+
|
| 108 |
+
else:
|
| 109 |
+
t0 = time.time()
|
| 110 |
+
full_text = ""
|
| 111 |
+
token_count = 0
|
| 112 |
+
for token_text in _engine.generate(
|
| 113 |
+
prompt=None, chat_messages=messages,
|
| 114 |
+
max_tokens=max_tokens, temperature=temperature,
|
| 115 |
+
):
|
| 116 |
+
full_text += token_text
|
| 117 |
+
token_count += 1
|
| 118 |
+
elapsed = time.time() - t0
|
| 119 |
+
|
| 120 |
+
self._send_json({
|
| 121 |
+
"id": request_id, "object": "chat.completion",
|
| 122 |
+
"model": _model_name, "created": int(time.time()),
|
| 123 |
+
"choices": [{
|
| 124 |
+
"index": 0,
|
| 125 |
+
"message": {"role": "assistant", "content": full_text},
|
| 126 |
+
"finish_reason": "stop",
|
| 127 |
+
}],
|
| 128 |
+
"usage": {
|
| 129 |
+
"prompt_tokens": 0,
|
| 130 |
+
"completion_tokens": token_count,
|
| 131 |
+
"total_tokens": token_count,
|
| 132 |
+
},
|
| 133 |
+
"timings": {
|
| 134 |
+
"total_ms": elapsed * 1000,
|
| 135 |
+
"tokens_per_second": token_count / elapsed if elapsed > 0 else 0,
|
| 136 |
+
}
|
| 137 |
+
})
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def run_server(sniper_dir: str, host: str = "0.0.0.0", port: int = 8899,
|
| 141 |
+
cache_size: int = 2000):
|
| 142 |
+
"""Start the OpenAI-compatible server."""
|
| 143 |
+
global _engine, _model_name
|
| 144 |
+
|
| 145 |
+
print("=" * 50)
|
| 146 |
+
print(" mlx-expert-sniper: server")
|
| 147 |
+
print("=" * 50)
|
| 148 |
+
print("\n Loading...", end="", flush=True)
|
| 149 |
+
|
| 150 |
+
_engine = SniperEngine.from_dir(sniper_dir, max_cached_experts=cache_size)
|
| 151 |
+
_model_name = f"sniper-{_engine.config.model_type}"
|
| 152 |
+
print(" ready.")
|
| 153 |
+
|
| 154 |
+
print(f"\n Server: http://{host}:{port}")
|
| 155 |
+
print(f" API: http://localhost:{port}/v1/chat/completions")
|
| 156 |
+
print(f" Models: http://localhost:{port}/v1/models")
|
| 157 |
+
print(f" Health: http://localhost:{port}/health")
|
| 158 |
+
print(f"\n{'=' * 50}")
|
| 159 |
+
print(" Listening...\n")
|
| 160 |
+
|
| 161 |
+
server = HTTPServer((host, port), _Handler)
|
| 162 |
+
try:
|
| 163 |
+
server.serve_forever()
|
| 164 |
+
except KeyboardInterrupt:
|
| 165 |
+
print("\n Shutting down...")
|
| 166 |
+
server.shutdown()
|
| 167 |
+
_engine.close()
|
src/mlx_expert_sniper/sniper.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SniperEngine: the core inference engine for expert sniping.
|
| 3 |
+
|
| 4 |
+
Implements the proven forward pass from research/expert-sniper/:
|
| 5 |
+
1. Attention (pinned weights, always in RAM)
|
| 6 |
+
2. Router → mx.eval() → top-k expert indices
|
| 7 |
+
3. Cache lookup → pread misses from SSD
|
| 8 |
+
4. gather_qmm for fused expert computation
|
| 9 |
+
5. Weighted sum of expert outputs
|
| 10 |
+
|
| 11 |
+
Measured: 4.33 tok/s on Qwen3-30B-A3B (17.2 GB) with 0.87 GB RAM.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import gc
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import time
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
import mlx.core as mx
|
| 22 |
+
import mlx.nn as nn
|
| 23 |
+
from mlx.utils import tree_flatten
|
| 24 |
+
|
| 25 |
+
from .config import SniperConfig
|
| 26 |
+
from .cache import PerExpertCache, ExpertReader
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class SniperEngine:
|
| 30 |
+
"""MoE expert sniping inference engine for MLX.
|
| 31 |
+
|
| 32 |
+
Usage:
|
| 33 |
+
engine = SniperEngine.from_dir("~/models/qwen3-30b")
|
| 34 |
+
for token in engine.generate("What is 2+2?"):
|
| 35 |
+
print(token, end="", flush=True)
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
def __init__(self, config: SniperConfig):
|
| 39 |
+
self.config = config
|
| 40 |
+
self.model = None
|
| 41 |
+
self.tokenizer = None
|
| 42 |
+
self.reader = None
|
| 43 |
+
self.expert_cache = None
|
| 44 |
+
self.kv_cache = None
|
| 45 |
+
self._loaded = False
|
| 46 |
+
|
| 47 |
+
@classmethod
|
| 48 |
+
def from_dir(cls, sniper_dir: str, **overrides) -> "SniperEngine":
|
| 49 |
+
"""Create engine from a sniper directory."""
|
| 50 |
+
config = SniperConfig.from_dir(sniper_dir, **overrides)
|
| 51 |
+
engine = cls(config)
|
| 52 |
+
engine.load()
|
| 53 |
+
return engine
|
| 54 |
+
|
| 55 |
+
def load(self) -> float:
|
| 56 |
+
"""Load model skeleton + pinned weights. Returns pinned size in GB."""
|
| 57 |
+
cfg = self.config
|
| 58 |
+
|
| 59 |
+
# Import model class based on model_type
|
| 60 |
+
model_cls, args_cls = self._get_model_classes()
|
| 61 |
+
|
| 62 |
+
with open(cfg.config_json_path) as f:
|
| 63 |
+
model_config = json.load(f)
|
| 64 |
+
|
| 65 |
+
# Build empty model skeleton
|
| 66 |
+
self.model = model_cls(args_cls.from_dict(model_config))
|
| 67 |
+
|
| 68 |
+
# Apply quantization matching stored format
|
| 69 |
+
quant = model_config.get("quantization", {})
|
| 70 |
+
self._quantize_model(model_config, quant)
|
| 71 |
+
|
| 72 |
+
# Load pinned (non-expert) weights
|
| 73 |
+
pinned = mx.load(cfg.pinned_path)
|
| 74 |
+
if hasattr(self.model, "sanitize"):
|
| 75 |
+
pinned = self.model.sanitize(pinned)
|
| 76 |
+
self.model.load_weights(list(pinned.items()), strict=False)
|
| 77 |
+
|
| 78 |
+
# Evaluate ONLY pinned params (skip switch_mlp expert weights)
|
| 79 |
+
params = [p for name, p in tree_flatten(self.model.parameters())
|
| 80 |
+
if "switch_mlp" not in name]
|
| 81 |
+
mx.eval(*params)
|
| 82 |
+
pinned_gb = sum(p.nbytes for p in params) / 1e9
|
| 83 |
+
del pinned
|
| 84 |
+
gc.collect()
|
| 85 |
+
|
| 86 |
+
# Initialize I/O and cache
|
| 87 |
+
self.reader = ExpertReader(cfg.bin_dir, num_workers=cfg.num_io_workers)
|
| 88 |
+
self.expert_cache = PerExpertCache(max_experts=cfg.max_cached_experts)
|
| 89 |
+
|
| 90 |
+
# Load tokenizer — try sniper dir first, then HF name
|
| 91 |
+
from transformers import AutoTokenizer
|
| 92 |
+
tokenizer_path = cfg.sniper_dir
|
| 93 |
+
if not os.path.exists(os.path.join(tokenizer_path, "tokenizer.json")):
|
| 94 |
+
tokenizer_path = cfg.tokenizer_name or cfg.sniper_dir
|
| 95 |
+
self.tokenizer = AutoTokenizer.from_pretrained(
|
| 96 |
+
tokenizer_path, trust_remote_code=True)
|
| 97 |
+
|
| 98 |
+
# Set MLX memory limits
|
| 99 |
+
mx.set_memory_limit(int(cfg.memory_limit_gb * 1024**3))
|
| 100 |
+
mx.set_cache_limit(cfg.cache_limit_mb * 1024**2)
|
| 101 |
+
|
| 102 |
+
self._loaded = True
|
| 103 |
+
return pinned_gb
|
| 104 |
+
|
| 105 |
+
def _get_model_classes(self):
|
| 106 |
+
"""Get model and args classes for the model type."""
|
| 107 |
+
mt = self.config.model_type
|
| 108 |
+
if mt in ("qwen3_moe", "qwen2_moe"):
|
| 109 |
+
from mlx_lm.models.qwen3_moe import Model, ModelArgs
|
| 110 |
+
return Model, ModelArgs
|
| 111 |
+
raise ValueError(f"Unsupported model_type: {mt}. "
|
| 112 |
+
f"Currently supported: qwen3_moe, qwen2_moe")
|
| 113 |
+
|
| 114 |
+
def _quantize_model(self, model_config: dict, quant: dict):
|
| 115 |
+
"""Apply quantization matching the stored format."""
|
| 116 |
+
def class_predicate(path, module):
|
| 117 |
+
# Respect per-path quantization overrides from config
|
| 118 |
+
if path in model_config.get("quantization", {}):
|
| 119 |
+
return model_config["quantization"][path]
|
| 120 |
+
if not hasattr(module, "to_quantized"):
|
| 121 |
+
return False
|
| 122 |
+
return True
|
| 123 |
+
|
| 124 |
+
nn.quantize(
|
| 125 |
+
self.model,
|
| 126 |
+
group_size=quant.get("group_size", 64),
|
| 127 |
+
bits=quant.get("bits", 4),
|
| 128 |
+
mode=quant.get("mode", "affine"),
|
| 129 |
+
class_predicate=class_predicate,
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
def reset_kv_cache(self):
|
| 133 |
+
"""Reset the KV cache for a new conversation."""
|
| 134 |
+
from mlx_lm.models.cache import make_prompt_cache
|
| 135 |
+
self.kv_cache = make_prompt_cache(self.model)
|
| 136 |
+
|
| 137 |
+
def forward_token(self, input_ids: mx.array) -> mx.array:
|
| 138 |
+
"""Run one forward pass with expert sniping.
|
| 139 |
+
|
| 140 |
+
This is the proven forward pass: attention (pinned) → router →
|
| 141 |
+
mx.eval(indices) → cache/pread experts → gather_qmm → combine.
|
| 142 |
+
"""
|
| 143 |
+
from mlx_lm.models.base import create_attention_mask
|
| 144 |
+
|
| 145 |
+
cfg = self.config
|
| 146 |
+
bits = cfg.bits
|
| 147 |
+
group_size = cfg.group_size
|
| 148 |
+
|
| 149 |
+
h = self.model.model.embed_tokens(input_ids)
|
| 150 |
+
mask = create_attention_mask(h, self.kv_cache[0])
|
| 151 |
+
|
| 152 |
+
for i, layer in enumerate(self.model.model.layers):
|
| 153 |
+
# ── Attention (pinned weights, always in RAM) ──
|
| 154 |
+
normed = layer.input_layernorm(h)
|
| 155 |
+
attn_out = layer.self_attn(normed, mask=mask, cache=self.kv_cache[i])
|
| 156 |
+
h = h + attn_out
|
| 157 |
+
mx.eval(h) # Must eval before router (data-dependent)
|
| 158 |
+
|
| 159 |
+
# ── Router: compute expert scores ──
|
| 160 |
+
normed = layer.post_attention_layernorm(h)
|
| 161 |
+
gates = layer.mlp.gate(normed)
|
| 162 |
+
gates = mx.softmax(gates, axis=-1, precise=True)
|
| 163 |
+
k = layer.mlp.top_k
|
| 164 |
+
inds = mx.argpartition(gates, kth=-k, axis=-1)[..., -k:]
|
| 165 |
+
scores = mx.take_along_axis(gates, inds, axis=-1)
|
| 166 |
+
if layer.mlp.norm_topk_prob:
|
| 167 |
+
scores = scores / scores.sum(axis=-1, keepdims=True)
|
| 168 |
+
|
| 169 |
+
# ── CRITICAL: eval indices before loading experts ──
|
| 170 |
+
mx.eval(inds, scores)
|
| 171 |
+
|
| 172 |
+
# ── Cache lookup + SSD read for misses ──
|
| 173 |
+
active = sorted(set(int(e) for e in np.array(inds).flatten()))
|
| 174 |
+
cached_experts = {}
|
| 175 |
+
misses = []
|
| 176 |
+
for eid in active:
|
| 177 |
+
c = self.expert_cache.get(i, eid)
|
| 178 |
+
if c is not None:
|
| 179 |
+
cached_experts[eid] = c
|
| 180 |
+
else:
|
| 181 |
+
misses.append(eid)
|
| 182 |
+
|
| 183 |
+
if misses:
|
| 184 |
+
loaded = self.reader.get_experts(i, misses)
|
| 185 |
+
for eid in misses:
|
| 186 |
+
self.expert_cache.put(i, eid, loaded[eid])
|
| 187 |
+
cached_experts[eid] = loaded[eid]
|
| 188 |
+
del loaded
|
| 189 |
+
|
| 190 |
+
# ── Remap global expert IDs → local [0..K-1] for gather_qmm ──
|
| 191 |
+
id_to_local = {eid: j for j, eid in enumerate(active)}
|
| 192 |
+
local_inds = mx.array(
|
| 193 |
+
np.vectorize(lambda x: id_to_local.get(int(x), 0))(np.array(inds)))
|
| 194 |
+
|
| 195 |
+
# ── Stack active experts into (K, ...) tensors ──
|
| 196 |
+
gw = mx.stack([cached_experts[e]["gate_proj.weight"] for e in active])
|
| 197 |
+
gs = mx.stack([cached_experts[e]["gate_proj.scales"] for e in active])
|
| 198 |
+
gb = mx.stack([cached_experts[e]["gate_proj.biases"] for e in active])
|
| 199 |
+
uw = mx.stack([cached_experts[e]["up_proj.weight"] for e in active])
|
| 200 |
+
us = mx.stack([cached_experts[e]["up_proj.scales"] for e in active])
|
| 201 |
+
ub = mx.stack([cached_experts[e]["up_proj.biases"] for e in active])
|
| 202 |
+
dw = mx.stack([cached_experts[e]["down_proj.weight"] for e in active])
|
| 203 |
+
ds = mx.stack([cached_experts[e]["down_proj.scales"] for e in active])
|
| 204 |
+
db = mx.stack([cached_experts[e]["down_proj.biases"] for e in active])
|
| 205 |
+
del cached_experts
|
| 206 |
+
|
| 207 |
+
# ── gather_qmm: fused quantized expert computation ──
|
| 208 |
+
x_exp = mx.expand_dims(normed, (-2, -3))
|
| 209 |
+
|
| 210 |
+
# SwiGLU: silu(gate_proj(x)) * up_proj(x)
|
| 211 |
+
go = mx.gather_qmm(x_exp, gw, scales=gs, biases=gb,
|
| 212 |
+
rhs_indices=local_inds, transpose=True,
|
| 213 |
+
group_size=group_size, bits=bits)
|
| 214 |
+
uo = mx.gather_qmm(x_exp, uw, scales=us, biases=ub,
|
| 215 |
+
rhs_indices=local_inds, transpose=True,
|
| 216 |
+
group_size=group_size, bits=bits)
|
| 217 |
+
hid = nn.silu(go) * uo
|
| 218 |
+
|
| 219 |
+
# down_proj
|
| 220 |
+
do = mx.gather_qmm(hid, dw, scales=ds, biases=db,
|
| 221 |
+
rhs_indices=local_inds, transpose=True,
|
| 222 |
+
group_size=group_size, bits=bits)
|
| 223 |
+
|
| 224 |
+
# Squeeze extra dims from gather_qmm output
|
| 225 |
+
while do.ndim > 4:
|
| 226 |
+
do = do.squeeze(-2)
|
| 227 |
+
|
| 228 |
+
# Weighted sum of expert outputs
|
| 229 |
+
h = h + (do * scores[..., None]).sum(axis=-2)
|
| 230 |
+
del gw, gs, gb, uw, us, ub, dw, ds, db
|
| 231 |
+
|
| 232 |
+
h = self.model.model.norm(h)
|
| 233 |
+
return self.model.lm_head(h)
|
| 234 |
+
|
| 235 |
+
def generate(self, prompt, max_tokens=None, temperature=None,
|
| 236 |
+
chat_messages=None):
|
| 237 |
+
"""Generate tokens, yielding each as a string.
|
| 238 |
+
|
| 239 |
+
Args:
|
| 240 |
+
prompt: Text prompt (used if chat_messages is None)
|
| 241 |
+
max_tokens: Maximum tokens to generate
|
| 242 |
+
temperature: Sampling temperature (0 = greedy)
|
| 243 |
+
chat_messages: List of {"role": ..., "content": ...} dicts
|
| 244 |
+
|
| 245 |
+
Yields:
|
| 246 |
+
str: Generated token text
|
| 247 |
+
"""
|
| 248 |
+
if not self._loaded:
|
| 249 |
+
raise RuntimeError("Engine not loaded. Call load() first.")
|
| 250 |
+
|
| 251 |
+
max_tokens = max_tokens or self.config.max_tokens
|
| 252 |
+
temperature = temperature if temperature is not None else self.config.temperature
|
| 253 |
+
|
| 254 |
+
# Tokenize
|
| 255 |
+
if chat_messages:
|
| 256 |
+
text = self.tokenizer.apply_chat_template(
|
| 257 |
+
chat_messages, tokenize=False,
|
| 258 |
+
add_generation_prompt=True, enable_thinking=False)
|
| 259 |
+
else:
|
| 260 |
+
text = self.tokenizer.apply_chat_template(
|
| 261 |
+
[{"role": "user", "content": prompt}],
|
| 262 |
+
tokenize=False, add_generation_prompt=True,
|
| 263 |
+
enable_thinking=False)
|
| 264 |
+
|
| 265 |
+
tokens = self.tokenizer.encode(text)
|
| 266 |
+
input_ids = mx.array([tokens])
|
| 267 |
+
|
| 268 |
+
# Reset KV cache
|
| 269 |
+
self.reset_kv_cache()
|
| 270 |
+
|
| 271 |
+
# Prefill
|
| 272 |
+
logits = self.forward_token(input_ids)
|
| 273 |
+
mx.eval(logits)
|
| 274 |
+
|
| 275 |
+
# Sample first token
|
| 276 |
+
next_token = self._sample(logits[:, -1, :], temperature)
|
| 277 |
+
|
| 278 |
+
# Autoregressive generation
|
| 279 |
+
for _ in range(max_tokens):
|
| 280 |
+
if next_token in {151643, 151645}: # EOS tokens
|
| 281 |
+
break
|
| 282 |
+
word = self.tokenizer.decode([next_token])
|
| 283 |
+
if "<|im_end|>" in word or "<|endoftext|>" in word:
|
| 284 |
+
break
|
| 285 |
+
yield word
|
| 286 |
+
|
| 287 |
+
input_ids = mx.array([[next_token]])
|
| 288 |
+
logits = self.forward_token(input_ids)
|
| 289 |
+
mx.eval(logits)
|
| 290 |
+
next_token = self._sample(logits[:, -1, :], temperature)
|
| 291 |
+
|
| 292 |
+
def _sample(self, logits: mx.array, temperature: float) -> int:
|
| 293 |
+
"""Sample next token from logits."""
|
| 294 |
+
if temperature <= 0.01:
|
| 295 |
+
return int(mx.argmax(logits, axis=-1).item())
|
| 296 |
+
probs = mx.softmax(logits / temperature, axis=-1)
|
| 297 |
+
return int(mx.random.categorical(mx.log(probs + 1e-10)).item())
|
| 298 |
+
|
| 299 |
+
def close(self):
|
| 300 |
+
"""Clean up resources."""
|
| 301 |
+
if self.reader:
|
| 302 |
+
self.reader.close()
|
| 303 |
+
self.expert_cache = None
|
| 304 |
+
self.model = None
|
| 305 |
+
self.tokenizer = None
|
stream_preprocess.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Stream-preprocess: download one shard at a time, process it, delete it.
|
| 4 |
+
Avoids needing 17 GB free for the full model download.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
python3 stream_preprocess.py
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import sys
|
| 12 |
+
import json
|
| 13 |
+
import time
|
| 14 |
+
import gc
|
| 15 |
+
import shutil
|
| 16 |
+
|
| 17 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
import mlx.core as mx
|
| 21 |
+
|
| 22 |
+
REPO = "mlx-community/Qwen3-30B-A3B-4bit"
|
| 23 |
+
OUTPUT_DIR = os.path.expanduser("~/models/qwen3-30b")
|
| 24 |
+
PAGE_SIZE = 16384
|
| 25 |
+
|
| 26 |
+
TENSOR_NAMES = [
|
| 27 |
+
"gate_proj.weight", "gate_proj.scales", "gate_proj.biases",
|
| 28 |
+
"up_proj.weight", "up_proj.scales", "up_proj.biases",
|
| 29 |
+
"down_proj.weight", "down_proj.scales", "down_proj.biases",
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def convert_layer_to_bin(layer_data, layer_idx, num_experts, output_dir):
|
| 34 |
+
tensor_info = {}
|
| 35 |
+
expert_block_size = 0
|
| 36 |
+
for name in TENSOR_NAMES:
|
| 37 |
+
t = layer_data[name]
|
| 38 |
+
per_expert_shape = list(t.shape[1:])
|
| 39 |
+
if t.dtype == mx.uint32:
|
| 40 |
+
elem_size = 4
|
| 41 |
+
elif t.dtype in (mx.bfloat16, mx.float16):
|
| 42 |
+
elem_size = 2
|
| 43 |
+
else:
|
| 44 |
+
elem_size = 4
|
| 45 |
+
nbytes = 1
|
| 46 |
+
for s in per_expert_shape:
|
| 47 |
+
nbytes *= s
|
| 48 |
+
nbytes *= elem_size
|
| 49 |
+
tensor_info[name] = {
|
| 50 |
+
"shape_per_expert": per_expert_shape,
|
| 51 |
+
"dtype": str(t.dtype).replace("mlx.core.", ""),
|
| 52 |
+
"nbytes": nbytes,
|
| 53 |
+
"inner_offset": expert_block_size,
|
| 54 |
+
}
|
| 55 |
+
expert_block_size += nbytes
|
| 56 |
+
|
| 57 |
+
header = {
|
| 58 |
+
"layer_idx": layer_idx,
|
| 59 |
+
"num_experts": num_experts,
|
| 60 |
+
"layout": {
|
| 61 |
+
"expert_block_size": expert_block_size,
|
| 62 |
+
"data_start": PAGE_SIZE,
|
| 63 |
+
"tensors": tensor_info,
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
header_bytes = json.dumps(header, indent=2).encode()
|
| 67 |
+
assert len(header_bytes) < PAGE_SIZE
|
| 68 |
+
header_bytes += b"\x00" * (PAGE_SIZE - len(header_bytes))
|
| 69 |
+
|
| 70 |
+
out_path = os.path.join(output_dir, "bin", f"moe_layer_{layer_idx:02d}.bin")
|
| 71 |
+
with open(out_path, "wb") as f:
|
| 72 |
+
f.write(header_bytes)
|
| 73 |
+
for expert_id in range(num_experts):
|
| 74 |
+
for name in TENSOR_NAMES:
|
| 75 |
+
t = layer_data[name][expert_id]
|
| 76 |
+
if t.dtype == mx.bfloat16:
|
| 77 |
+
raw = np.array(t.astype(mx.float16)).astype(np.float16).tobytes()
|
| 78 |
+
elif t.dtype == mx.uint32:
|
| 79 |
+
raw = np.array(t).astype(np.uint32).tobytes()
|
| 80 |
+
else:
|
| 81 |
+
raw = np.array(t).tobytes()
|
| 82 |
+
f.write(raw)
|
| 83 |
+
|
| 84 |
+
return os.path.getsize(out_path)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def main():
|
| 88 |
+
from huggingface_hub import hf_hub_download, HfApi
|
| 89 |
+
|
| 90 |
+
print("=" * 55)
|
| 91 |
+
print(" Stream Preprocess — one shard at a time")
|
| 92 |
+
print(f" Model: {REPO}")
|
| 93 |
+
print(f" Output: {OUTPUT_DIR}")
|
| 94 |
+
print("=" * 55)
|
| 95 |
+
|
| 96 |
+
os.makedirs(os.path.join(OUTPUT_DIR, "bin"), exist_ok=True)
|
| 97 |
+
|
| 98 |
+
# Download config + tokenizer files (small)
|
| 99 |
+
for fname in ["config.json", "tokenizer.json", "tokenizer_config.json",
|
| 100 |
+
"special_tokens_map.json"]:
|
| 101 |
+
try:
|
| 102 |
+
path = hf_hub_download(REPO, fname, local_dir="/tmp/sniper_dl")
|
| 103 |
+
shutil.copy(path, os.path.join(OUTPUT_DIR, fname))
|
| 104 |
+
print(f" Downloaded {fname}")
|
| 105 |
+
except Exception as e:
|
| 106 |
+
print(f" Skipped {fname}: {e}")
|
| 107 |
+
|
| 108 |
+
# Get shard list
|
| 109 |
+
with open(os.path.join(OUTPUT_DIR, "config.json")) as f:
|
| 110 |
+
config = json.load(f)
|
| 111 |
+
num_layers = config.get("num_hidden_layers", 48)
|
| 112 |
+
|
| 113 |
+
# Download the index to find shard names
|
| 114 |
+
idx_path = hf_hub_download(REPO, "model.safetensors.index.json",
|
| 115 |
+
local_dir="/tmp/sniper_dl")
|
| 116 |
+
with open(idx_path) as f:
|
| 117 |
+
idx = json.load(f)
|
| 118 |
+
shards = sorted(set(idx["weight_map"].values()))
|
| 119 |
+
print(f"\n {len(shards)} shards to process")
|
| 120 |
+
|
| 121 |
+
# Check which layers already done
|
| 122 |
+
existing = set()
|
| 123 |
+
for f in os.listdir(os.path.join(OUTPUT_DIR, "bin")):
|
| 124 |
+
if f.startswith("moe_layer_") and f.endswith(".bin"):
|
| 125 |
+
existing.add(int(f.split("_")[2].split(".")[0]))
|
| 126 |
+
if existing:
|
| 127 |
+
print(f" Already done: layers {sorted(existing)}")
|
| 128 |
+
|
| 129 |
+
pinned = {}
|
| 130 |
+
layers_done = set(existing)
|
| 131 |
+
|
| 132 |
+
# Track partial layers that span shards
|
| 133 |
+
partial_layers = {}
|
| 134 |
+
|
| 135 |
+
for si, shard_name in enumerate(shards):
|
| 136 |
+
print(f"\n [{si+1}/{len(shards)}] Downloading {shard_name}...")
|
| 137 |
+
t0 = time.time()
|
| 138 |
+
|
| 139 |
+
shard_path = hf_hub_download(REPO, shard_name, local_dir="/tmp/sniper_dl")
|
| 140 |
+
dl_time = time.time() - t0
|
| 141 |
+
shard_size = os.path.getsize(shard_path) / 1e9
|
| 142 |
+
print(f" Downloaded {shard_size:.1f} GB in {dl_time:.0f}s")
|
| 143 |
+
|
| 144 |
+
print(f" Loading tensors...")
|
| 145 |
+
data = mx.load(shard_path)
|
| 146 |
+
print(f" {len(data)} tensors")
|
| 147 |
+
|
| 148 |
+
# Classify
|
| 149 |
+
layer_experts = {}
|
| 150 |
+
for key, tensor in data.items():
|
| 151 |
+
if "switch_mlp" in key:
|
| 152 |
+
layer = int(key.split(".layers.")[1].split(".")[0])
|
| 153 |
+
short = key.split(".switch_mlp.")[1]
|
| 154 |
+
layer_experts.setdefault(layer, {})[short] = tensor
|
| 155 |
+
else:
|
| 156 |
+
pinned[key] = tensor
|
| 157 |
+
|
| 158 |
+
# Convert complete expert layers
|
| 159 |
+
for layer_idx, tensors in layer_experts.items():
|
| 160 |
+
if layer_idx in layers_done:
|
| 161 |
+
continue
|
| 162 |
+
|
| 163 |
+
# Merge with partial data from previous shards
|
| 164 |
+
if layer_idx in partial_layers:
|
| 165 |
+
partial_layers[layer_idx].update(tensors)
|
| 166 |
+
tensors = partial_layers[layer_idx]
|
| 167 |
+
|
| 168 |
+
if len(tensors) < 9:
|
| 169 |
+
# Partial — save for later
|
| 170 |
+
partial_layers[layer_idx] = tensors
|
| 171 |
+
print(f" Layer {layer_idx}: partial ({len(tensors)}/9 tensors)")
|
| 172 |
+
continue
|
| 173 |
+
|
| 174 |
+
num_experts = tensors[list(tensors.keys())[0]].shape[0]
|
| 175 |
+
sz = convert_layer_to_bin(tensors, layer_idx, num_experts, OUTPUT_DIR)
|
| 176 |
+
layers_done.add(layer_idx)
|
| 177 |
+
if layer_idx in partial_layers:
|
| 178 |
+
del partial_layers[layer_idx]
|
| 179 |
+
print(f" Layer {layer_idx}: {sz/1e6:.0f} MB")
|
| 180 |
+
|
| 181 |
+
del data, layer_experts
|
| 182 |
+
gc.collect()
|
| 183 |
+
mx.clear_cache()
|
| 184 |
+
|
| 185 |
+
# Delete the downloaded shard to free disk
|
| 186 |
+
try:
|
| 187 |
+
os.remove(shard_path)
|
| 188 |
+
print(f" Deleted shard ({shard_size:.1f} GB freed)")
|
| 189 |
+
except:
|
| 190 |
+
pass
|
| 191 |
+
|
| 192 |
+
# Handle remaining partial layers
|
| 193 |
+
if partial_layers:
|
| 194 |
+
print(f"\n {len(partial_layers)} partial layers remain — re-downloading...")
|
| 195 |
+
for layer_idx, tensors in partial_layers.items():
|
| 196 |
+
if layer_idx in layers_done:
|
| 197 |
+
continue
|
| 198 |
+
if len(tensors) >= 9:
|
| 199 |
+
num_experts = tensors[list(tensors.keys())[0]].shape[0]
|
| 200 |
+
sz = convert_layer_to_bin(tensors, layer_idx, num_experts, OUTPUT_DIR)
|
| 201 |
+
layers_done.add(layer_idx)
|
| 202 |
+
print(f" Layer {layer_idx}: {sz/1e6:.0f} MB (merged)")
|
| 203 |
+
|
| 204 |
+
# Save pinned if we don't already have it or if it's stale
|
| 205 |
+
pinned_path = os.path.join(OUTPUT_DIR, "pinned.safetensors")
|
| 206 |
+
if pinned:
|
| 207 |
+
print(f"\n Saving pinned ({len(pinned)} tensors)...")
|
| 208 |
+
mx.save_safetensors(pinned_path, pinned)
|
| 209 |
+
psz = os.path.getsize(pinned_path) / 1e9
|
| 210 |
+
print(f" Pinned: {psz:.2f} GB")
|
| 211 |
+
else:
|
| 212 |
+
psz = os.path.getsize(pinned_path) / 1e9 if os.path.exists(pinned_path) else 0
|
| 213 |
+
|
| 214 |
+
# Clean up temp downloads
|
| 215 |
+
shutil.rmtree("/tmp/sniper_dl", ignore_errors=True)
|
| 216 |
+
|
| 217 |
+
# Summary
|
| 218 |
+
import glob
|
| 219 |
+
bin_files = sorted(glob.glob(os.path.join(OUTPUT_DIR, "bin", "moe_layer_*.bin")))
|
| 220 |
+
total = sum(os.path.getsize(f) for f in bin_files)
|
| 221 |
+
print(f"\n Expert layers: {len(bin_files)}/{num_layers}")
|
| 222 |
+
print(f" Expert total: {total/1e9:.2f} GB")
|
| 223 |
+
print(f" Pinned: {psz:.2f} GB")
|
| 224 |
+
print(f" Total: {(total/1e9 + psz):.2f} GB")
|
| 225 |
+
|
| 226 |
+
missing = set(range(num_layers)) - layers_done
|
| 227 |
+
if missing:
|
| 228 |
+
print(f"\n WARNING: Missing layers: {sorted(missing)}")
|
| 229 |
+
else:
|
| 230 |
+
print(f"\n All {num_layers} layers converted!")
|
| 231 |
+
print(f"\n Test with:")
|
| 232 |
+
print(f" mlx-sniper run {OUTPUT_DIR} -p 'What is 2+2?' -v")
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
if __name__ == "__main__":
|
| 236 |
+
main()
|