Tech7 min read

Qwen3.6-35B MoE on M1 Max: pre-warming hot experts didn't beat the mmap cache

IkesanContents

I got per-token expert selection logs out of Qwen’s MoE router, so I tried using them for a pre-inference warm-up: read the hot experts into memory before generating. Hot experts here are the experts the router picked especially often while choosing 8 out of 256 per token.

The Kimi K3 report stopped at the top-16-of-896 routing spec, and the SwiftLM hands-on never even reached SSD streaming: the OS mmap page cache covered everything.

If a log can tell me the hot experts ahead of time, is loading them into memory beforehand faster than leaving everything to the mmap cache? I benchmarked both under identical conditions.

Test environment

ItemDetails
MachineMacBook Pro M1 Max, 64GB unified memory
OSmacOS 26.5 (Darwin, build 25F71)
Python3.13 (miniconda base)
mlx0.31.2
mlx-lm0.31.3
Target modelunsloth/Qwen3.6-35B-A3B-UD-MLX-4bit (dynamic quant based on 4-bit, 21.7GB / ~20.2GiB)
Larger model I also triedmlx-community/Qwen3.5-122B-A10B-4bit (4-bit quant, 69.6GB / ~64.8GiB)
MoE architectureqwen3_5_moe (shares Qwen3NextSparseMoeBlock with the Qwen3-Next family)
Generationmlx_lm.stream_generate, default sampling parameters

35B-A3B routes top-8 out of 256 experts, and all 40 layers are MoE. 122B-A10B is the same qwen3_5_moe family with more layers and parameters.

Hooking the router

The router part of Qwen3NextSparseMoeBlock boils down to three lines.

gates = self.gate(x)
gates = mx.softmax(gates, axis=-1, precise=True)
inds = mx.argpartition(gates, kth=-k, axis=-1)[..., -k:]  # 選ばれたexpert index

Instead of editing site-packages, I monkey-patched Qwen3NextSparseMoeBlock.__call__ after loading the model: copy the original implementation verbatim, and add only the code that records inds per layer and token.

At first I called mx.eval() every time inds came out, but that creates one sync point per layer per token and blocks the memory reuse MLX gets from lazy evaluation. I ended up batching: when a token reaches the last MoE layer (layer 39 on 35B-A3B), evaluate that token’s inds in a single call, cutting the sync points by a factor of the layer count.

The router hook, the A/B experiment, and the safety wrapper are all on GitHub → LiltingChannelLabo

I prepared five prompts in deliberately different genres.

KeyContent
bstInsert function for a binary search tree (Python)
bbsMinimal BBS implementation (HTML)
kana_introCharacter self-introduction
mathFast Fibonacci via matrix exponentiation
cnSelf-introduction in Chinese

122B-A10B died on Metal running out of memory

I tried the bigger model first. Putting a 69.6GB (~64.8GiB) 4-bit model on a 64GB unified-memory machine was obviously a stretch, so I loaded it with mlx_lm.load(model_id, lazy=True).

Loading finished in a second or two. mlx_lm.load() defaults to lazy=False, which eagerly evaluates every parameter inside the load call. But even with lazy=True, free memory dropped by tens of GB during the very first prefill (the phase that processes the whole prompt at once).

Judging a safe zone from outside the process using vm_stat free memory never stabilized, no matter how many times I changed the threshold.

Watch swap, not free memory

Across several runs, free memory swung between nearly 50GB and a few dozen MB, while swap usage never moved at all.

A read-only mmapped safetensors file consumes free memory as the OS pulls pages into the page cache, but unmodified pages need no write-back: the OS can drop them (without going through swap) and re-read them from the file when needed. A drop in free memory alone can’t tell you whether memory is actually under pressure. MLX’s own counter (mx.get_active_memory()) only counts MLX-managed buffers; right after model load it stayed at active=0.00GB, unrelated to the free-memory plunge.

Once I changed the danger criterion to swap growth only, the process stopped getting killed from outside and ran to completion.

Metal itself still reported out-of-memory

With the swap-based guard in place, generation now failed with a clear error from mlx_lm.

libc++abi: terminating due to uncaught exception of type std::runtime_error:
[METAL] Command buffer execution failed: Insufficient Memory
(00000008:kIOGPUCommandBufferCallbackErrorOutOfMemory)

This wasn’t the guard script: Metal itself failed to allocate the memory needed to execute a GPU command buffer, and the process died on an uncaught C++ exception. The system never froze, and swap usage never moved.

Setting an explicit cap with mx.set_memory_limit() changed nothing. Per the documentation, the limit is a “guideline”: an allocation only raises an exception once the limit is exceeded and RAM including swap is exhausted. Lowering the value made no practical difference.

lazy=True only delays when weights get evaluated; it doesn’t promise a lower peak during the forward pass. In practice, the 4-bit 122B-A10B did not fit this 64GB unified-memory machine through mlx_lm’s plain generate implementation. SwiftLM had automatically classified this model under its “SSD STREAMING” strategy, reading it from SSD incrementally.

I switched to 35B-A3B, which reliably fits in 64GB.

Observing experts on 35B-A3B

35B-A3B ran without trouble. MLX’s counter read active=0.00GB right after load, then settled around 18.5GB as generation progressed, which matches the model size.

I ran the 5 prompts at max_tokens=150 and counted the unique experts selected per layer (out of 256).

PromptGeneration timeUnique experts per layer (of 256)
bst4.0s158.3
bbs3.3s156.4
kana_intro3.4s149.3
math3.2s161.5
cn3.2s147.9

Every prompt used around 60% of the experts per layer (147.9 to 161.5). Each token only picks top-8, but the union over 150 tokens spread the selection across more than half of the experts. The premise that pre-reading just the top hot experts should be enough mostly didn’t hold at this generation length.

Pre-warm vs plain mmap cache

Reading SwitchLinear/QuantizedSwitchLinear (mlx_lm/models/switch_layers.py), each MoE layer stores its expert weights as a single (num_experts, output_dims, input_dims) array. Quantized models go through QuantizedSwitchLinear, where gather_qmm does the matrix multiply while selecting experts via rhs_indices.

x = mx.gather_qmm(x, self["weight"], self["scales"], ...,
                   rhs_indices=indices, transpose=True, ...)

Using this, I implemented the warm-up as one dummy-input gather_qmm call over the observed hot experts, executed before generation.

I compared two conditions in the same process with the same prompt. Condition A is plain stream_generate with no warm-up; condition B first pre-reads the per-layer top-20 experts from the observation log with this warm-up, then generates the same prompt.

PromptCondition A (plain)Condition B (top-20 pre-warmed)Diff
bst61.83 tok/s61.80 tok/s-0.05%
kana_intro62.02 tok/s61.92 tok/s-0.16%

The warm-up itself took 0.02 seconds, but in generation tok/s the A/B difference stayed within noise. Both prompts showed the same pattern, with B marginally lower; no meaningful improvement.

Since A and B run in the same process in that order, B does get a bias in its favor worth the first prefill’s cold start. But the generation speed being compared is a decode-side metric (tokens emitted one at a time), so prefill differences barely touch this number.

The 4-bit 35B-A3B is 21.7GB, and the plain mmap cache already delivered 61.8 to 62.0 tok/s. Even knowing in advance which experts would be selected, only noise-level cold-fault cost was left for the warm-up to shave off. Whether this changes at a scale where the model doesn’t fit in memory and SSD I/O becomes the real bottleneck, i.e. the 122B-A10B that wouldn’t run here, is unverified: plain mlx_lm generate couldn’t even start generating.