Running Qwen3.8-Flash-Next on a 32 GB Mac with Transformers

Experts streamed from disk through a bounded device cache · Apple M5 MacBook Pro, 32 GB unified memory, MPS · torch 2.15 · compared with Strata's published Mac numbers

Strata runs Qwen3.8-Flash-Next on a 32 GB Mac by streaming its experts from the SSD, since they are far too large to fit in memory. This page is a proof of concept that does the same with Transformers: from_pretrained with a new experts_offload option, running on PyTorch MPS instead of a dedicated engine.

10.0 tok/sdecode, median of four different 200-token answers, cold cache first (story alone: 10.7). Strata, M4 Mac mini, closest quality (IQ3_XXS): 9.2; its 13.5 headline uses Q2_0, ~10% worse perplexity. The M5 has 28% more bandwidth
67–70 tok/sprefill of a 4K-token prompt in 1K chunks, cold cache (Strata on the M4: 65.8 with IQ3_XXS, 93.6 with Q2_0)
121 GB → 49 GBof experts (FP8 → 3-bit), never loaded: 12 GiB of them cached on the device
17.5 GiBdevice memory after loading, cache included; 8-bit dense layers keep BF16 perplexity

Upstreaming expert streaming as from_pretrained(..., experts_offload=...): huggingface/transformers#50717 · RFC. The Qwen3.8-specific parts (disk-backed n-gram table, Metal decode kernels), the converter and every benchmark script live in tarekziade/moe-expert-streaming, a package on top of that PR.

Interactive architecture requires JavaScript. See the Architecture section below for the full model, storage and kernel details.

Results

Same protocol as Strata's Mac guide, decode tok/s on 200-token answers (greedy) and prefill tok/s on a 4,059-token prompt, OS page cache on, with one change: decode runs four different prompts once each (a story, Python code, a history explanation, French grammar), starting from a cold cache, instead of repeating the story. The expert cache carries over from one generation to the next, so a repeated prompt replays the same routing on a warm cache (91% hits instead of 89% for the story), and the story is among the fastest of the four: 10.7 tok/s, against 9.4–9.5 for the history and French answers, whose hit rate is 87%. Other apps closed (an open browser costs ~0.7 tok/s on this Mac). Strata's numbers are its published ones, not rerun here. Its Mac guide describes them as "medians of 5 interleaved runs" (story and short-question decodes of 200 tokens, a 4K-token prompt, no MTP) but does not give the prompts, the warmup, or whether a run repeats a prompt on a warm cache, so the comparison stays approximate.

EngineExpertsDense layersExpert cacheDecode tok/sPrefill tok/s (4K)Perplexity
Strata (published, M4 Mac mini 32 GB)IQ3_XXS (3.06 bits/weight), closest to our 3-bit expertspack quant11.5–14.7 GB9.265.84.119 vs 4.052 for Q8_0 (+1.6%) ‡
Strata (published, M4 Mac mini 32 GB)Q2_0pack quant11.5–14.7 GB13.593.64.475 vs 4.052 for Q8_0 (+10.4%) ‡
Transformers (M5)recommended3-bit (~3.5 bits/weight with scales and biases)8-bit12 GiB10.7, 10.7, 9.5, 9.4
median 10.0 (3 runs)
– †4.901
Transformers (M5)3-bit8-bit10 GiB–67.5, 69.2 (1K chunks, cold)4.901
Transformers (M5)3-bit4-bit8 GiB6.4, 6.4 *77.1, 80.1 *5.355 (+9.3%)
Transformers (M5)3-bitBF164 GiB5.0, 4.9 *72.7, 79.9 *4.901

Perplexity on 1,024 tokens of Transformers documentation with the same 3-bit experts; 8-bit dense layers pick the same top-1 token as BF16 at 95% of positions, 4-bit at 82%. 8 I/O threads. † a 4K prefill with a 12 GiB cache needs more free memory than this Mac had with its usual apps open (it ran at 60–70 tok/s on a quieter day); with 10 GiB it fits. Long prompts are prefilled in 1,024-token chunks, as Strata does on Macs under 96 GB. * measured before the read-pipeline and decode-kernel work, two runs of the same prompt (warm cache for the second). Strata's M4 runs use no MTP. Through the decode path itself (teacher-forced, one token per step, 512 tokens), perplexity is 3.3558 with BF16 dense layers and 3.3629 with 8-bit ones (+0.2%, same top-1 token at 94.5% of positions; data, script). ‡ Strata's quality table, as quoted by ModelFit: perplexity relative to its own Q8_0 build, on text we do not know, so it is not comparable with our absolute numbers (ours compare dense-layer precisions with the same 3-bit experts).

Decode after a 6K-token document

Strata's third Mac row: 200 tokens decoded after reading a long document, so the attention layers see ~6K earlier tokens and the prefill has just churned the expert cache.

EngineExpertsExpert cachePromptDecode tok/sPrefill tok/s
Strata (published, M4 Mac mini 32 GB)IQ3_XXS11.5–14.7 GB6K document7.7–
Strata (published, M4 Mac mini 32 GB)Q2_011.5–14.7 GB6K document13.1–
Transformers (M5), 8-bit dense3-bit12 GiB6,062 tokens, 1K chunks, cold7.3 (1 run)64.5
Transformers (M5), 8-bit dense3-bit10 GiB6,062 tokens, 1K chunks, cold7.1 (1 run)64.5

The document is the 4K prompt's synthetic harbour records, extended to 6K tokens; ask: summarize them. The 6K prefill peaks at 25.4 GiB of device memory with a 12 GiB cache (24.0 with 10 GiB), so it needs other apps closed. Decode drops from 10.0 to 7.3: the cache holds what the prefill touched (56% of lookups missed over the whole run; 292 GB read from the SSD) and full attention covers 6K tokens. Still behind Strata's IQ3_XXS (7.7), on a faster Mac. The two cache sizes give the same answer for ~150 tokens, then different wording (the prefill groups experts differently, which changes the summation order). Reports: 12 GiB, 10 GiB.

A fair comparison

Strata publishes several expert quantizations; the one to compare with is the one closest in quality to ours, not the fastest:

Strata buildSame top-1 token as Q8_0Perplexity (Q8_0: 4.052)Decode on the M4 mini (tok/s)
IQ3_XXS84.5%4.119 (+1.6%)9.2
Q2_080.6%4.475 (+10.4%)13.5

The two Macs

MachineChipCPUGPUMemory bandwidthUnified memorySSD
Strata's (Mac mini)M410 cores (4P + 6E)10 cores120 GB/s32 GBsize not stated
This one (MacBook Pro 14")M510 cores (4P + 6E)10 cores, Neural Accelerators in each153 GB/s32 GB1 TB, 7.1 GB/s measured

Apple's launch specs. Strata's guide says "M4 Mac mini 32 GB", which is the base M4 (the M4 Pro Mac mini comes with 24, 48 or 64 GB). The M5 has ~28% more memory bandwidth and its SSD reads 7.1 GB/s even for the 2.15 MB random reads experts need, so numbers measured on the two Macs are not directly comparable (see "A fair comparison").

Architecture

Model: Qwen3.8-Flash-Next, 48 decoder layers, hidden size 2,560, vocabulary 248,320. Every layer has a token mixer and an MoE block, each wrapped in a 4-stream hyper-connection. Token mixers: 36 Gated DeltaNet (linear attention) layers and 12 full-attention layers (one every 4), whose attention is sparse: an indexer keeps at most 2,048 earlier tokens per query. MoE: 512 routed experts (top-10, intermediate size 640) plus one shared expert per layer. Layer 1 adds a per-layer n-gram embedding (320M rows × 160). MTP and vision are not loaded.

Where every weight lives

WeightsSizeStored asWhere
Routed experts (48 × 512)49 GB3-bit MLX affine, group 64, 2.15 MB per expertSSD; 12 GiB in the recommended device slot pool
N-gram embedding table51 GBFP8 (as in the checkpoint)SSD, read row by row (a few rows per token)
Dense layers (attention, Gated DeltaNet and hyper-connection projections, shared experts, lm_head)~5.3 GB8-bit MLX affine (MetalConfig)device
Embeddings, norms, routers, conv weights, small gates~1.3 GBBF16device

Converted once from Qwen/Qwen3.8-Flash-Next-FP8 by convert_fp8.py (dense to BF16, experts to 3-bit, n-gram table kept FP8); MetalConfig quantizes the dense layers at load time.

Kernels, per decode step

PartWhat runsKernel
Every quantized linear layermatrix × vector in decode; in prefill a matrix × matrix in float32 (see known issues) quantized_matmul of kernels-community/mlx-quantization-metal-kernels v2 (MLX's Metal kernels)
Routed expertsdecode: the 10 routed experts of each token, straight from their cache slots; prefill: rows sorted by expert, 64 experts per call, float32 gather_qmm (unsorted) / sorted gather_qmm, same kernel repository
Gated DeltaNet layer, decodeone matmul for the four input projections (merged weights); one kernel for the conv-state update, causal conv, SiLU and the gates; one kernel for the gated delta rule (q/k L2 norm, decay, delta update, float32 state in place) and the gated RMSNorm; then out_proj. ~34 ops → 4 dispatches, bit-exact with the PyTorch path Metal kernels in Transformers (integrations/metal_gated_delta_net.py, compiled with torch.mps.compile_shader) + quantized_matmul; used by Qwen3.5, Qwen3.5-MoE and Qwen3.8
Gated DeltaNet recurrence, prefillchunked gated delta rulePyTorch reference (flash-linear-attention needs Triton, unavailable on macOS)
Short convolution, gated RMSNorm, prefilldepthwise causal conv (kernel 4), RMSNorm × sigmoid gatePyTorch ops (causal_conv1d is CUDA-only)
Full attentionindexer (sparse token selection; skipped while there are at most 2,048 keys, where it selects every token), then attention under its maskPyTorch ops + MPS scaled_dot_product_attention (flash kernels take no sparse mask)
Hyper-connectionsgroup RMSNorm over 4 × 2,560, SiLU of the low-rank code, stream mixing with the injection weights; the three projections (2,560·4 → 320 → 2,560·4, and → 4)three Metal kernels in Transformers (integrations/metal_hyper_connection.py), bit-exact with the PyTorch path, + quantized_matmul (the → 320 and → 4 projections fused into one call)
Routers, norms, embeddings, samplingBF16PyTorch ops on MPS

Expert streaming

  1. Loading: from_pretrained(..., experts_offload={...}) builds the model without expert weights and loads everything else normally; quantization_config=MetalConfig(bits=8) quantizes the dense layers.
  2. Cache: one preallocated device pool of expert slots shared by all layers (LRU), with a per-layer table expert → slot kept on the host. 12 GiB hold ~6,000 of the 24,576 experts.
  3. Each MoE layer: the router's choices are copied to the host (the one host synchronization per layer), misses are read on 8 I/O threads into recycled pinned host buffers (one contiguous read per expert, through descriptors opened once), copied to free slots without blocking, and the layer sends its slot indices in one small copy and runs gather_qmm on the pool.
  4. Prediction: the routers of the next two layers run on the current hidden states; their guessed experts are read in the background, and the next layer's guesses are installed in the pool while the current layer computes.
  5. Prefill: a 4K prompt touches nearly every expert of every layer; they go through the sorted gather_qmm 64 at a time so the transient bank stays small.

Decode speedups since the first published numbers

Each measured as alternating A/B pairs (4 prompts per run, 12 GiB cache), commits on the POC branch:

ChangeDecode tok/s (before → after)Output
Queue the shared expert after the routing copy (pinned buffer + event), so it runs while the host does the cache bookkeeping8.25 → 8.55 (+3.6%)identical
Commit queued MPS work early: record an event after each decoder-layer sub-block and after the expert kernels8.52 → 9.40 (+10.3%)identical
Hyper-connections: one 8-bit matmul for the 10240 → 320 and 10240 → 4 projections (the 4-row call alone took 49 µs, twice the 320-row one)9.48 → 10.03 (+5.8%)rounding differs; decode-path perplexity 3.3642 → 3.3629 (512 tokens), top-1 agreement 96.7%

The MPS commit issue. PyTorch's MPS stream encodes small kernels into a command buffer and commits it only at the next synchronization (or under memory pressure). Decode is made of small kernels and synchronizes once per MoE layer, so after each routing wait the GPU idled while the host encoded the experts and the next layer, then ran them only at the next wait. Launching 1,000 small matmuls and then doing 106 ms of host work takes 213 ms; recording an event after the launches (it commits without waiting) brings it to 109 ms (reproducer). With the experts replaced by zeros, the same fix takes decode from 67 to 56 ms per token. It would be best fixed in PyTorch; Transformers works around it with experts_offload={"mps_commit": True} (the default on MPS).

Do these changes help other MoEs?

Two of the three decode changes are generic, one targets a family of models, one is Qwen3.8-only:

ChangeApplies toChecked
Routing copy to a pinned buffer behind an eventevery streamed MoEOLMoE: identical output, never slower
Early MPS commits (mps_commit)every streamed MoE on MPS (hooks on each decoder layer's sub-blocks)OLMoE: +3% when streaming, more while the cache fills; Qwen3.8: +10%
Shared expert queued after the routing copyMoE blocks with gate, shared_expert and shared_expert_gate: Qwen2-MoE, Qwen3-Next, Qwen3.5-MoE, Qwen3-Omni (talker), Qwen3.8all five blocks have the same forward, which the override reproduces; for upstreaming it should be an explicit opt-in per model rather than a match on attribute names
Fused hyper-connection projectionsQwen3.8 only (its own kernel file)–

OLMoE-1B-7B (16 layers × 64 experts, 4-bit experts, 3.4 GB), same four prompts, 200 tokens, greedy, cold cache first; before is the branch just before the three changes (script, reports):

Expert cacheBefore (decode median tok/s)Now, commits offNowOutput
1 GiB (streaming, 85% hits; 2 runs each)29.0, 32.732.7, 32.133.2, 33.8identical text; perplexity 8.2953 (prefill), 8.2484 (decode path) in every run
4 GiB (all experts fit; 1 run each)60.2–73.2identical text

Streaming OLMoE gains ~3%, within run-to-run noise: its dense layers are small, so the GPU had little host encoding to wait for. With a cache that holds every expert, the gain comes from the first two answers, while the cache fills (66–67 against 47–48 tok/s); once every expert is resident and no layer synchronizes, both run at the same speed (79 / 84 against 72 / 85). Unrelated to these changes: with every expert resident, prefill takes a different matmul path (perplexity 8.2144 instead of 8.2953) and one of the four answers differs from the streamed one, before and after alike.

What still limits decode

The dense layers, measured by ablation: experts replaced by zeros, then each module category replays its first output instead of computing, which shows what it would save if it were free (data, script). Floor: reading the category's 8-bit weights once at the M5's 153 GB/s.

ms per token (dense part: 56 ms)MeasuredWeight-read floor
Gated DeltaNet (36 layers)21.913.6
Hyper-connections (97), before the projection fusion (~5 ms less after)14.54.2
Full attention (12 layers, with the indexer)6.84.0
lm_head5.74.2
Shared experts (48)2.61.5
Routers (48)2.10.4
PLE n-gram embedding (disk rows; its own host sync cost 10 ms before the commit fix)1.0–

Would a warmup or a smarter cache policy help? No

The routing of the four decode prompts (plus the story again) was logged and replayed through a cache simulator at the 12 GiB size (5,991 expert slots), counting only the experts the model asks for (no next-layer guesses, hence a few points below the measured hit rates). Belady's policy evicts the expert needed furthest in the future: no policy can beat it.

Simulated hit rateStoryPythonHistoryFrench
LRU, cold cache (current)86.2%86.0%82.1%81.3%
LFU86.6%84.7%79.5%80.1%
LRU, warmed by the previous prompt–86.9%82.9%82.5%
25% of the cache pinned to the experts most used by the other prompts, LRU for the rest83.8%84.3%78.8%79.4%
Same, 50% pinned80.4%81.9%77.3%76.3%
Belady (optimal)89.3%88.9%87.4%86.8%

One 200-token answer touches 10.7–12.6k distinct experts, about twice what the cache holds, and 10–13% of its lookups are first uses. For the story, the optimum at 12 GiB equals that first-use floor (89.3%), and LRU reaches it with a 1.5× cache. Consecutive prompts share only 51–72% of their experts, so warming up gains about one point, and pinning "popular" experts loses up to 10. New experts keep appearing through the answer (1.7 per layer and token at the start, 0.2 by token 175), so long conversations would gain a little from a better policy, but a 200-token benchmark would not. Replay output, simulator, routing trace.

CUDA (preview): one A10G

The same converted model also runs on an NVIDIA A10G (24 GB) through the same package (run, reports): BF16 dense layers, 3-bit experts in an 8 GiB device cache, run by a Triton kernel that unpacks the MLX format in registers (matches PyTorch at bfloat16 precision for 2-8 bits, check). The pod has 83.5 GiB of RAM and 22.8 CPUs; the experts sit on a network file system.

A10G, same 4 promptsDecode tok/sMedian
Experts read through the page cache2.8, 3.6, 4.5, 4.84.1
All experts preloaded in a pinned host tier (as Strata keeps them in RAM)5.8, 5.8, 5.7, 5.8 (repeat: 5.9, 5.8, 5.8, 5.8)5.8
Host tier filled on demand (no preload)2.9, 3.2, 4.1, 4.73.6

Quality: decode-path perplexity (teacher-forced, 512 tokens) is 3.4366 on CUDA (BF16 dense) vs 3.3558 on the Mac (BF16 dense); the two agree on the top-1 token 95.3% of the time. The host tier (host_cache_bytes, host_preload) is now in the PR. An answer that differed in an earlier run came from CUDA decode not being bit-deterministic run to run, not from the host tier.

Early numbers. Decode is held back by the dense layers, which run as plain PyTorch ops on CUDA: the Metal kernels (Gated DeltaNet step, hyper-connections) have no CUDA counterparts yet, and the per-expert prefill path makes a 4K prompt slow (17 tok/s).

Known issues

Files: transformers.patch (against huggingface/transformers 158f872eb) · raw reports, converter and benchmark.