waltgrace commited on
Commit
2d8a9bb
·
verified ·
1 Parent(s): d4ead01

v0.1.0: MoE expert sniping for MLX — run models larger than your RAM

Browse files
.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()