GLM-5.2 (753B MoE) in 2-bit experts + NVFP4 attention on two DGX Sparks (TP2), with a W4A16 dspark speculator at K=3 — 25.8 tok/s single-stream at 96K context.
* segment sizes marked with an asterisk are estimates pending a measured run
Eval scores
(compare all)Bench card
No measured runs yet — be the first: install the spark-benchmark skill.
Overview
Run GLM-5.2 (753B-parameter MoE) across two DGX Sparks with vLLM-Moet's 2-bit expert kernels. Everything here is measured on real hardware (GB10, sm_121, 121 GiB unified LPDDR5x per node, CUDA 13, driver 580.159.03). No Docker — vLLM 0.24.0 ships aarch64 wheels. This is Sapid Labs' own runbook; the helper scripts encode every GB10-specific memory workaround, so you can run it without understanding all six. Widening the NVFP4 cut past the big-3 attention linears was measured and rejected: it buys up to ~6% decode but costs ~11 points of GSM8K, because the speed and the quality damage both live in the shared-expert projections.
- 2 x DGX Spark (GB10, sm_121), tensor-parallel (TP2)
- vLLM-Moet 2-bit expert kernels (2-bit MoE W2 planes)
- Expert-pruned planes: 256->208 per layer (REAP-style) so they stay page-cache-resident
- NCCL-over-RoCE (200G ConnectX), FULL cudagraphs (needs NCCL >= 2.30.7)
- NVFP4 on the big-3 attention linears (o_proj/q_b_proj/kv_b_proj) + top-k4 routing
- W4A16 dspark speculator at K=3 (sapidlabs/Sparkulator-GLM-5.2) - 2.60 tok/verify, pos-0 acceptance 0.744
- 25.8 tok/s single-stream at 96K context, measured paired on the frozen 52-prompt set (25.79 / 25.84 / 25.46 across three independent boots)
- Quality: GSM8K-50 92% strict, reproduced across two sessions
- Wider NVFP4 cuts were measured and REJECTED twice: data-free packing costs ~11pt of GSM8K, and an activation-weighted repack recovers GSM8K fully (0.92) at +7.5% decode but loops on long-form prose (2 of 6 prompts) — see troubleshooting
- Only same-session paired comparisons are meaningful on this box - see troubleshooting
Software requirements
- 1–2 DGX Sparks with a working driver (nvidia-smi shows GB10)
- 200G ConnectX link up between nodes, static IPs, passwordless SSH both ways
- ~900 GiB disk per node for GLM-5.2 (planes + checkpoint)
- Python 3.12; vLLM 0.24.0 (aarch64 wheels); Ray 2.56.0
- NCCL ≥ 2.30.7 staged at ~/nccl-2.30.7/libnccl.so.2 on both nodes (from the nvidia-nccl-cu13 wheel)
- Root access once, for the memlock limit fix (full-speed RDMA)
Quick start
- 1
Install vLLM-Moet (each node)
vLLM 0.24.0 has aarch64 wheels — no Docker, no source build. Create the venv, clone the spark-gb10 branch, apply the three patches, pin deps, and build DeepGEMM. Do this on both nodes, or install once and rsync the venv + repo to the peer (identical OS/user).
python3.12 -m venv ~/venvs/vllm-moet ~/venvs/vllm-moet/bin/pip install vllm==0.24.0 "ray[default]==2.56.0" git clone -b spark-gb10 https://github.com/Sapid-Labs/vLLM-Moet ~/Dev/vLLM-Moet cd ~/venvs/vllm-moet/lib/python3.12/site-packages git apply ~/Dev/vLLM-Moet/patch/vllm-moet-v0.24.0.patch git apply ~/Dev/vLLM-Moet/spark/spark-unified-memory.patch git apply ~/Dev/vLLM-Moet/spark/sync-pp-spec-sched-fix.patch # dep pins (see docs/v024-port.md for why) ~/venvs/vllm-moet/bin/pip uninstall -y flashinfer-cubin ~/venvs/vllm-moet/bin/pip install flashinfer-python==0.6.14 git clone https://github.com/deepseek-ai/DeepGEMM ~/Dev/DeepGEMM cd ~/Dev/DeepGEMM && git checkout a6b593d2826719dcf4892609af7b84ee23aaf32a git submodule update --init --depth 1 third-party/cutlass third-party/fmt ~/venvs/vllm-moet/bin/python setup.py bdist_wheel ~/venvs/vllm-moet/bin/pip install --no-deps dist/deep_gemm-*.whlbashSecond node: rsync -a ~/venvs/vllm-moet peer:~/venvs/ and rsync -a ~/Dev/vLLM-Moet peer:~/Dev/ is sufficient.
- 2
Lift the memlock limit (one root one-liner, per node)
DGX OS ships an 8 MB memlock limit that breaks NCCL-over-RDMA (ibv_reg_mr: Cannot allocate memory). Without this the scripts still run over TCP, ~15% slower.
sudo tee /etc/security/limits.d/99-rdma-memlock.conf <<< $'youruser soft memlock unlimited\nyouruser hard memlock unlimited'bashApplies to new login sessions; the cluster script self-SSHes for this reason.
- 3
Get the pruned TP2 planes (each node downloads only its shard)
Never let the server convert weights at load time — on unified memory that OOMs the box. Download the exact expert planes we serve: 2-bit, expert-pruned (208/layer), TP2-sharded, ~79 GB per rank. Both nodes also need the base GLM-5.2-FP8 checkpoint (config/tokenizer/non-expert weights).
hf download zai-org/GLM-5.2-FP8 --local-dir ~/models/hf/GLM-5.2-FP8 # on .1 (rank 0): hf download sapidlabs/GLM-5.2-2bit-MoE-planes-pruned208-tp2 --include 'rank0/*' \ --local-dir /tmp/planes && mv /tmp/planes/rank0 ~/models/hf/GLM-5.2-FP8/moe_w2_planes_tp2_p208 # on .2 (rank 1): same command with 'rank1/*'bashOr produce them yourself: spark/prepack_planes.py (TP2 variant) then spark/routing/prune_planes.py with spark/routing/keep208.json (row-select, no requant, ~15 min).
- 4
Start the Ray cluster and serve GLM-5.2 (from the head node .1)
Needs NCCL ≥ 2.30.7 staged on both nodes — Torch's bundled 2.28.9 deadlocks TP2 FULL cudagraph replay on GB10+CX7. start-ray-cluster.sh brings up raylets on both nodes, delivers the NCCL pin via VLLM_NCCL_SO_PATH, and resolves each node's RoCEv2 GID index (they drift across reboots — check its '== RoCEv2 GID indexes ==' banner if NCCL dies at init).
~/Dev/vLLM-Moet/spark/start-ray-cluster.sh # raylets on both nodes, memory-capped VLLM_MOE_W2_PREPACKED_DIR=$HOME/models/hf/GLM-5.2-FP8/moe_w2_planes_tp2_p208 \ MTP_K=1 ~/Dev/vLLM-Moet/spark/serve-glm52-tp2-mtp.sh ~/Dev/vLLM-Moet/spark/warm-planes.sh ~/models/hf/GLM-5.2-FP8/moe_w2_planes_tp2_p208 --peerbashBoot log must show POOL-PRUNED 256->208 (once per MoE layer per rank) or the pruned planes weren't picked up. Expect ~15 tok/s single-stream after a few hundred tokens settle. Re-run warm-planes.sh after any cache purge.
- 5
(Optional) Fast build — 25.8 tok/s single-stream (current best)
Stack four levers on the base config: NVFP4 on just the big-3 attention linears (o_proj, q_b_proj, kv_b_proj — the full attention+shared cut degrades quality, so builds ship big-3 only; all other dense stays FP8, experts stay 2-bit), REAP-calibrated planes, top-k=4 routing (num_experts_per_tok: 4, the s12 win), and the W4A16 dspark speculator at K=2. Needs the dspark venv port applied on both nodes (dspark-port/apply.sh) and the speculator at the SAME path on both nodes. The 4-bit speculator is a drop-in for the BF16 one — same weights, same acceptance, 4.57 GiB instead of 7.09 — but it only works with the quantized-drafter fixes in the port, which is why the port has to be re-applied.
hf download sapidlabs/Sparkulator-GLM-5.2 --local-dir ~/models/hf/Sparkulator-GLM-5.2 # both nodes ~/Dev/vLLM-Moet/dspark-port/apply.sh ~/venvs/vllm-moet/lib/python3.12/site-packages/vllm # both nodes ~/Dev/vLLM-Moet/spark/start-ray-cluster.sh M=$HOME/models/hf/GLM-5.2-FP8 DSPARK_K=3 MODEL=$M/nvfp4_big3_overlay \ SPECULATOR=$HOME/models/hf/Sparkulator-GLM-5.2 \ VLLM_MOE_W2_PREPACKED_DIR=$M/moe_w2_planes_tp2_p208_reap \ VLLM_NVFP4_DENSE=1 VLLM_NVFP4_TARGETS=o_proj,q_b_proj,kv_b_proj \ VLLM_ENGINE_READY_TIMEOUT_S=2400 VLLM_MOE_W2_FADVISE_GLOB= \ ~/Dev/vLLM-Moet/spark/serve-glm52-tp2-dspark.sh --hf-overrides '{"num_experts_per_tok":4}' \ --max-model-len 98304 --kv-cache-memory-bytes 10737418240bashContext: the script defaults to 8192/2G, but KV is heavily under-provisioned there — the trailing --max-model-len/--kv-cache-memory-bytes override it (vLLM takes the last flag). 10G measured free at 96K; 12G/128K costs ~6% decode. K=3 is the peak (24.28 vs 23.15 at K=2 once acceptance was measured on real content); K=4 loses ~10% because its fourth draft slot is accepted only 1 time in 10. Leave VLLM_DSPARK_CONF_TAU / VLLM_DSPARK_CONF_MIN_K (dynamic K) unset — measured NEGATIVE, not merely null: tau=0.5 costs 23%. First ~10 runs after boot are slow (2→26 tok/s) as the serve script purges page cache and the planes fault back in — never benchmark cold. If you swap between BF16 and W4A16 speculators, clear the draft's compiled graph first (~/.cache/vllm/torch_compile_cache/<hash>/rank_*/eagle_head plus the matching torch_aot_compile/<sha>/) or set VLLM_DISABLE_COMPILE_CACHE=1 — the cache key doesn't include the drafter's quantization and you'll get 'ValueError: too many values to unpack'. PP2 fallback (simpler, slower, no NCCL pin): spark/serve-glm52-pp2.sh --eager.
Key vLLM parameters
| Parameter | Value | Purpose |
|---|---|---|
| VLLM_MOE_W2_PREPACKED_DIR | <planes dir> | Read planes from disk — no load-time conversion (which OOMs unified memory) |
| VLLM_MOE_W2_DELTA_GB | 0 | The FP4 delta tier double-allocates on unified memory |
| VLLM_MOE_W2_FADVISE_GLOB | (empty) | cudaMalloc can't reclaim page cache on GB10 |
| MALLOC_MMAP_THRESHOLD_ | 65536 | glibc arenas otherwise retain ~10 MB loader buffers |
| --kv-cache-memory-bytes | 10G (util 0.85) | vLLM's KV budget math counts system-wide usage on GB10, so pin KV explicitly. Rate is ~11,296 tokens/GiB; 10G buys 96K context for free, 12G buys 128K for ~6% decode. The ceiling is page-cache room for the 78.4 GiB of planes, not the model |
| systemd-run --scope -p MemoryMax | per-node cap | Contained failure — per-node commitments must sum below 121 GiB or a global OOM takes the desktop down |
| NCCL_IB_HCA + NCCL_IB_GID_INDEX | per-node | NCCL over RoCE beats TCP ~15%; needs the memlock fix |
| raylets from login shells | — | Ray actors inherit the raylet's PATH — flashinfer JIT needs ninja/nvcc |
| DSPARK_K | 3 | Speculative decode with the W4A16 dspark draft. K=3 beat K=2 (24.28 vs 23.15) once acceptance was measured on real content; K=4 loses (4th slot accepted ~1 in 10). |
| num_experts_per_tok | 4 | Native top-k=8 dropped to k=4 on the REAP-calibrated pruned pool - halves expert bytes per token for ~1.18x decode at GSM8K-50 92%. |
| VLLM_NVFP4_TARGETS | o_proj,q_b_proj,kv_b_proj | NVFP4 the big-3 attention linears ONLY. Adding fused_qkv_a_proj buys +1.6%; adding the shared expert (gate_up_proj,down_proj) buys ~4% more but drops GSM8K-50 from 0.92 to 0.81. Do not widen without a calibrated quant. |
| VLLM_DSPARK_CONF_TAU / _MIN_K | unset | Confidence-gated dynamic K. Measured NEGATIVE: tau=0.5 costs 23% (18.77 vs 24.40). Decode is bandwidth-bound on dense bytes paid once per step, so a low-acceptance draft slot is still profitable. Leave unset. |
| PYTORCH_CUDA_ALLOC_CONF / NCCL_*_NCHANNELS / NCCL_CUMEM_ENABLE | unset (defaults) | Measured NULL, don't bother. Paired same-session arms on the 52-prompt set: expandable_segments:True 25.49 vs 25.58 baseline (-0.34%, t=-0.94); NCCL_MIN/MAX_NCHANNELS=4 + NCCL_CUMEM_ENABLE=0 25.56 (-0.07%, t=-0.61). Flat across all 11 prompt categories with acceptance unchanged, so it isn't an averaging artefact. If you copy these from a larger GB10 cluster's recipe, note they were tuned for 8-node mixed traffic, not single-stream TP2. |
| VLLM_USE_RAY_V2_EXECUTOR_BACKEND / _COMPILED_DAG_OVERLAP_COMM / VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS | leave alone | All three are inert here and cost nothing to skip. V2 executor is ALREADY the default on 0.24.0 (envs.py annotates bool=False but the resolver defaults to "1" - read the lambda, not the annotation). The compiled-DAG overlap flag is only consumed by the V1 RayDistributedExecutor, so on V2 it is unreachable; setting both together is self-cancelling. And VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=0 does nothing once --kv-cache-memory-bytes is set, because gpu_worker.py early-returns the pinned value before the estimate is computed. |
API usage
Chat completion (OpenAI-compatible, port 8000)
Greedy (temperature 0) is deterministic and shows the clean ~20 tok/s; sampled throughput varies with MTP acceptance (~13-20).
# the served model id comes from GET http://127.0.0.1:8000/v1/models
curl http://127.0.0.1:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "GLM-5.2",
"messages": [{"role": "user", "content": "What is 47 × 89?"}],
"temperature": 0.0
}'bashCoherence + throughput check
Measure speed with usage.completion_tokens differentials (512 vs 1024), never by counting SSE frames — MTP bundles tokens per chunk. spark/demo.py does it right.
~/Dev/vLLM-Moet/spark/smoke.sh # coherence
python3 ~/Dev/vLLM-Moet/spark/demo.py # correct tok/s
python3 ~/Dev/vLLM-Moet/spark/demo_chat.py # interactive, live tok/s + acceptancebashTroubleshooting
- "Free memory … less than desired GPU memory utilization" at startup
- Page cache is squatting. Run `python3 spark/purge-cache.py ~/models/hf` on both nodes (the GLM script does this automatically).
- Engine killed at ~60% of load
- You're converting at load time (no prepacked planes found — check VLLM_MOE_W2_PREPACKED_DIR), or your per-node memory commitments sum above physical 121 GiB.
- `ray status` shows 1 node
- Peer raylet died or wrong IP; rerun `start-ray-cluster.sh` (it force-stops both sides first).
- Stuck placement groups after a failed launch
- `ray stop --force` on both nodes, `pkill -9 -f EngineCore`, then restart the cluster.
- nvidia-smi shows no memory numbers
- Normal on GB10; watch `free -g` instead. During decode, power.draw >~30 W means working; ~17-20 W means memory-stalled.
- Minutes-long cold TTFT / slow first requests
- Plane faults from NVMe. Run `spark/warm-planes.sh` after startup or any cache purge; don't mistake a cold prefill for a hang.
- A tuning change looks like a big win but you measured it against yesterday's number
- Only same-session paired arms count. Serve arm A, warm, sparkbench; then serve arm B, same warmup, sparkbench. A cross-boot comparison here once turned a real +5.9% into an apparent +12.4%.
- Draft acceptance is unchanged after a target-side change, so quality must be fine
- It does not follow. dspark acceptance measures draft-vs-target agreement and the draft is trained on the target's hidden states - both drift together. Run GSM8K-50; the full NVFP4 cut held acceptance at 0.739 while losing 11pt of GSM8K.
- TimeoutError: RPC call to sample_tokens timed out, mid-eval, no OOM
- Seen once at request 11/50 of GSM8K after 4 min of shm_broadcast stalls with KV only 2.8% full, preceded by a Triton JIT during inference. Did not reproduce. If it recurs, retry on a fresh VLLM_CACHE_ROOT, then --enforce-eager to test cross-node cudagraph replay.
- Building an NVFP4 overlay on the peer node reintroduces a silent quality bug
- Spark-2's ~/prepack_nvfp4_linear.py predates the shared-weight_scale_2 fix. vLLM keeps ONE weight_scale_2 per merged module (collapsed via .max()), so halves packed against their own amax dequantize with the wrong scale - measured 15.9x apart on q_a/kv_a. Build once on Spark-1, rsync to the peer, and verify 0/78 mismatched.
- A quantization change passes GSM8K but you have not checked long-form generation
- Not sufficient. An activation-weighted NVFP4 repack of this model passed GSM8K-50 at 0.92 TWICE and still degenerated on 2 of 6 long prose prompts (8-gram repetition 150 and 41; unique-word ratio 0.022) where the shipped build degenerated on 0 of 6. GSM8K is short and structured — sparkbench averages 291 output tokens, degeneration appears near 1500. Gate any quant change on a paired long-form probe (6 prompts x 1500 tokens, greedy) as well as an accuracy eval. Note also that looping inflates decode tok/s, since repetitive text is near-perfectly predicted by the draft.
Revision history
What has changed on this page since it was published, and what it measured. Newest first. Entries marked arena are promoted wins from the public engine arena — measured, not authored.
- docs
Backfilled this changelog. The recipe predates the `updates` convention, so its history — swapping the MTP head for the dspark speculator, quantizing that speculator to W4A16, and settling on K=3 — was written into the prose but never recorded as dated entries. That left it with no verifiable-since date, which ranked it maximally stale in refresh-queue.py despite being the most-worked recipe on the site.
No configuration change; this entry records the state the page already documents. Current serve: W4A16 dspark speculator (sapidlabs/Sparkulator-GLM-5.2, 4.57 GiB total) at DSPARK_K=3 — K=3 measured 24.28 tok/s vs 23.15 at K=2 once acceptance was taken on real content, and K=4 loses ~10% because its fourth draft slot is accepted about 1 time in 10. 25.8 tok/s single-stream at 96K context. The drafter quantization came from the quantized-drafter case study (2026-07-20), now recorded there as outcome `adopted`.
- performance
Quantized the dspark speculator to W4A16 and adopted it as what this recipe serves. Measured against the BF16 drafter with the target model, speculation depth (K=2) and workload held fixed, 15 repeats per arm back to back in one session.
+8.7% decode (20.21 → 21.97 tok/s mean, 2.8σ, 95% CI +0.55…+2.97) at no measurable acceptance cost (0.5166 → 0.5084, 0.7σ) — almost exactly the +9% the weight-read model predicted. Quantizing lm_head as well was measured and NOT adopted: 0.2σ gain against a 2.5σ acceptance cost, because it carries the highest quantization error in the model and emits the draft logits directly. K=2 beat K=3 on the BF16 drafter and ties it at W4A16; K=4 loses 9.6% because its fourth draft slot is accepted one time in ten.
Memory budget
The whole speed story is residency: experts are 2-bit-quantized and pruned 256→208 per MoE layer (REAP-style), shrinking the memory-mapped planes to ~79 GB/rank (from ~95 GB full-pool) so they stay page-cache-resident and decode runs fault-free. Heads-up: the older "111 of 114" figure was the full-pool config (95 GB planes); with pruned planes the box measures ~110/121 GB used. The segments drawn here are the base 15 tok/s config — 2-bit planes + FP8 dense + a native MTP draft + MLA-tiny KV. The fast build keeps the 79 GB planes, NVFP4s only the big-3 attention linears (the full attention+shared cut degrades quality), and swaps the MTP head for the dspark speculator — now W4A16-quantized at 4.57 GiB total (~2.29/rank, TP2-sharded), down from 7.09 GiB BF16. Planes, dense, and KV are measured/stated; the activations figure is estimated — no clean per-segment resident total is recorded, so treat the sum as indicative. On context: KV is NOT the binding constraint here. At --kv-cache-memory-bytes 2G the engine reports a 22,592-token KV cache against a --max-model-len of 8192, so single-stream context can be raised toward ~20K on the existing budget without touching KV at all; the 2.5 GiB freed by quantizing the speculator (~1.26/rank) is worth roughly another 14,000 KV tokens if you need concurrency instead. Spend either carefully — it comes out of the same pool that keeps the 79 GB of planes resident, and evicted planes drop decode to the ~2 tok/s cold floor.