Serving GLM-5.2-504B on RTX PRO 6000: the vLLM sparse attention fix on SM120
A missing vLLM layer pattern caused repetitive GLM-5.2 output on SM120. This note explains the fix and the configuration that restored inference with 240K-token context.
On this page · 12 sections
- 01The setup
- 02Failure: deterministic repetition
- 03A separate NCCL failure
- 04The dense fallback isolated attention
- 05Root cause: a missing index_topk_pattern
- 06Validation on sparse attention
- 07Reaching 240K context with DCP
- 08Cutting the 288GB load time to four minutes
- 09Scale-to-zero deployment
- 10What I would reuse
- 11Credits
- 12Related work
I served the 504-billion-parameter GLM-5.2 checkpoint on eight RTX PRO 6000 Blackwell GPUs in a GCP g4-standard-384 Spot VM. The model loaded, but its output became repetitive after a few tokens. The sparse-attention kernel looked broken on SM120. It was not. The checkpoint had a null index_topk_pattern, so vLLM ran the sparse indexer on layers that should have skipped it. Deriving the pattern from indexer_types fixed the output.
This note follows the investigation: the tests that ruled out decoding, the dense fallback that isolated attention, the unrelated NCCL failure, and the final sparse configuration that served cold prompts up to 240K tokens.
The setup
- Model
0xSero/GLM-5.2-504B- Architecture
glm_moe_dsa— Multi-head Latent Attention (MLA) plus a DeepSeek-V3.2 sparse path where a small "lightning indexer" (index_n_heads=32,index_topk=2048) picks which keys each query attends to.- Layers
78 layers, first 3 dense
- Experts
168 routed experts in this REAP-pruned checkpoint
- Weights
NVFP4 weights (~288 GB on disk)
- Hardware
8× RTX PRO 6000 Blackwell, SM120, tensor-parallel size 8
- Serving
vLLM, weights streamed from a GCS bucket, on a Spot VM that deletes itself when idle
Most published GLM-5.2 and DeepSeek-V3.2 sparse-attention results use B200 (SM100) or H100/H200 (SM90) GPUs. The RTX PRO 6000 uses Blackwell with a different SM target, SM120. Kernel coverage differs between SM100 and SM120.
Failure: deterministic repetition
The first working boot used a prebuilt vLLM image built for SM120 (the "b12x" image, below). It served, but every generation collapsed into a loop. A prompt about France returned Paris. France France France France and kept going. Math prompts never reached an answer. The collapse was deterministic — it happened at temperature 0 and at every prompt length — which rules out sampling noise.
What I ruled out
The repetition looked like a decoding bug, so each decoding-side cause was changed on its own and tested. Each one was a full reload of a 288 GB checkpoint, so this was not cheap:
- no change
NCCL version
swapped
- no change
Decode context parallelism (DCP)
disabled
- no change
KV-cache dtype
fp8vsauto - no change
CUDA-graph capture
--enforce-eagerto disable it - no change
Multi-token prediction (MTP)
disabled
- no change
Tensor-parallel degree
4 vs 8
None of these changes affected the output. That pointed away from decoding and toward attention. The sparse kernel was not broken on SM120. Attention was misconfigured. The difference was one configuration field.
A separate NCCL failure
Where it fails
Switching images to test a dense fallback surfaced a second, unrelated failure: NCCL aborted during startup inside graph/search.cc, before the model could load across the 8 GPUs.
Cause
The cause was an environment variable baked into the image: NCCL_GRAPH_FILE="". NCCL read the empty string as a path to a topology graph file, tried to open it, and failed. A variable set to empty is not the same as an unset variable.
Fix
- Unset, not emptybash
# Baked into the image, breaks NCCL graph search: # NCCL_GRAPH_FILE= # Fix — remove it entirely, do not set it to "": unset NCCL_GRAPH_FILE
The failure looked like a Blackwell fabric problem, but removing the empty NCCL_GRAPH_FILE variable allowed NCCL to build its own topology and complete the 8-GPU initialization.
The dense fallback isolated attention
Before finding the real cause, one path already produced correct output: force the model onto vLLM's dense Triton MLA kernel, which runs on SM120. Three source edits to stock vllm/vllm-openai:v0.23.0 disable the sparse indexer:
# 1) vllm/platforms/cuda.py — SM100-only Blackwell check excludes SM120 (major 12)
# before: if device_capability.major == 10:
if device_capability.major >= 10:
# 2) vllm/model_executor/models/deepseek_v2.py — stop treating glm_moe_dsa as v3.2-sparse
self.is_v32 = hasattr(config, "index_topk") and \
getattr(config, "model_type", None) != "glm_moe_dsa"
# 3) same file, weight loop — the disabled indexer's weights have nowhere to go
for name, loaded_weight in weights:
if ".indexer" in name:
continue
Served with VLLM_ATTENTION_BACKEND=TRITON_MLA --enforce-eager, the prompts that had looped returned correct text: 17 × 23 = 391 and coherent prose. Dense attends to every key instead of the top 2048, so it is slower and gives up the long-context advantage of the sparse indexer, and --enforce-eager (needed here because CUDA-graph capture was flaky on this path) costs more at decode. Correct output on the dense path showed that the checkpoint, dequantization path, and MoE routing could work together. The remaining fault was specific to sparse attention.
Root cause: a missing index_topk_pattern
The sparse kernel was never broken on SM120. It was being told to build a full indexer on every layer.
Where it fails
GLM-5.2's config carries an indexer_types array — 78 entries, one per layer, marking each layer "full" or "shared". But vLLM's sparse path does not read indexer_types. It reads a separate field, index_topk_pattern — a 78-character F/S string (here 21 F, 57 S) that says which layers run the indexer and which skip it. In this checkpoint that field ships as null.
Cause
With it null, vLLM builds a full indexer on all 78 layers, and the 57 layers meant to skip corrupt attention. The configuration looked valid in local inspection, but the running model repeated output.
Fix
The fix is to derive the pattern from the checkpoint's own
indexer_typesand pass it in:Derived pattern · 21 F / 57 Sbash--hf-overrides '{"index_topk_pattern":"FFFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSS"}' # 78 chars, 21 F / 57 S
With the pattern supplied, the same b12x sparse kernel on the same 8 SM120 GPUs produces correct output — and vLLM logs confirm it now skips the indexer on the "S" layers instead of running all 78. The looping runs never passed this field; 0xSero's SM120 recipe does, which is why theirs worked and the first attempts did not.
Derive the string per checkpoint — do not copy the one above onto a different model. Read indexer_types from the model's config.json (78 entries here) and map each in order: full → F, shared → S. A different GLM-5.2 prune has a different array and a different string. This is arguably a packaging/ergonomics gap more than a kernel bug: the checkpoint already ships indexer_types, but vLLM reads only index_topk_pattern — which the config leaves null — and does not fall back to deriving it. Closest tracked report: vllm-project/vllm#46726.
unset NCCL_GRAPH_FILE
vllm serve /models/glm-5.2-504b \
--tensor-parallel-size 8 --quantization modelopt_fp4 \
--attention-backend B12X_MLA_SPARSE --moe-backend b12x \
--kv-cache-dtype fp8 --load-format fastsafetensors \
--hf-overrides '{"index_topk_pattern":"FFFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSSFSSS"}'
Validation on sparse attention
Validated on the 8-GPU SM120 box, all on the sparse path:
- passed
Greedy France prompt
The capital of France is Paris.(the exact prompt that had looped). - passed
17 × 23391. - passed
A financial-analysis task
read twelve quarterly income statements out of a ~6–30K-token document, fill an empty JSON with full-year revenue, net income, gross-margin %, and year-over-year growth — returned every figure exactly, including a deep single-value lookup.
- passed
A needle placed in the middle of a filler document
and retrieved correctly from a single cold prompt —
velvet-thunder-7788at both 128K and 240K tokens, no prefix cache.Caveat Cold long context works, but only with the DCP config in the ceiling section below; the minimal command above still tops out around 32K.
- measured
Decode
about 43–45 tokens/second — batch 1, steady-state, at short-to-medium context (a few hundred up to ~30K tokens including prefill).
Caveat Long-context decode is slower; do not read 45 tok/s as a 240K figure.
Reaching 240K context with DCP
The minimal sparse command above has a ceiling, and it is easy to hit. The b12x sparse indexer allocates a scratch workspace during startup profiling and locks it (about 708 MB). A single fresh prompt whose per-step indexer allocation needs more asserts and kills the engine:
Error
AssertionError: Workspace is locked but allocation from _run_b12x_paged_topk
requires 1412 MB, current size 708 MB. Workspace growth is not allowed after locking.Fix
The fix was to limit the work handled at each step instead of increasing the workspace. Two flags do it:
--enable-chunked-prefill --max-num-batched-tokens 8192--decode-context-parallel-size 8
Tried first
The wrong instinct — mine, first — is to make the workspace bigger by raising the prefill batch.
That fails twice: a large batch (131072) breaks
torch.compile, and warmup length does not size the workspace either (warming at 120K still locked at 708 MB, and the warmup itself crashed). On that path a single cold prompt tops out around 32K, and it is tempting to write the limitation down and move on.
- Query dimension
--enable-chunked-prefill --max-num-batched-tokens 8192— counterintuitively small. A 240K prompt prefills in ~30 chunks of 8192, so the indexer only ever sees an 8192-token query slice per step. That bounds the query dimension.- Key dimension
--decode-context-parallel-size 8— shards the KV along the sequence dimension across the 8 GPUs, so each rank's indexer scores only its 1/8 of the keys. That bounds the key dimension.
Together the per-step allocation stays under the 708 MB lock no matter how long the prompt is. It also needs several b12x environment variables. The required settings include VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=0, which prevents the profiler from locking the wrong reservation, and a cleared VLLM_B12X_MLA_EXTEND_MAX_CHUNKS value.
One reproducibility note that cost a boot to learn: the NCCL_P2P_DISABLE=1 and VLLM_ENABLE_PCIE_ALLREDUCE=0 flags this recipe ships are load-bearing on GCP's virtualized PCIe, not bare-metal leftovers. Dropping them to chase faster collectives enables a custom PCIe all-reduce that crashes with custom_all_reduce.cuh:455 'invalid argument' and crash-loops the engine. Leave them on.
With that config, cold single prompts of 48K, 128K, and 240K all served with no assert, and the needle came back correctly from a fresh 240K prompt. The cost is prefill throughput: those small 8192 chunks serialize a long prefill (~1000 tok/s; a cold 240K prompt takes about 256 seconds before the first token). Prefix caching hides most of it in practice — the long document is prefilled once and every follow-up question reuses the KV — but a cold, unique long prompt pays the full ingest.
| Measure | Minimal command | DCP config | Change |
|---|---|---|---|
| Largest cold single prompt | around 32K | 240K | better |
This is 0xSero's mechanism, not mine: borrowing only their index_topk_pattern fixed correctness but left this ceiling, and it took their full launch config to see the answer was smaller chunks plus DCP, not a bigger workspace.
Cutting the 288GB load time to four minutes
Weights stream from a GCS bucket over a gcsfuse mount. Stock vLLM reads the checkpoint one shard at a time — about 106 seconds per shard, roughly 20 minutes before the server accepts a request, all of it billed at 8-GPU Spot rates.
| Measure | Stock vLLM | fastsafetensors | Change |
|---|---|---|---|
| Full load | roughly 20 minutes | about 4 minutes | better |
fastsafetensors reads shards in parallel and brings the full load to about 4 minutes:pip install fastsafetensors # then: vllm serve ... --load-format fastsafetensors
Scale-to-zero deployment
The serving VM is deleted when it becomes idle. A watchdog reads vLLM's /metrics. When running and waiting request counts reach zero and token counters stop moving for a set period, the watchdog deletes the VM through the Compute API. The cold path includes VM provisioning and the roughly four-minute model load. The model runs while it serves requests and for a short idle period after them.
What I would reuse
The specific bug was a null configuration field. The reusable method is:
- A deterministic failure that survives every decoding-side toggle is not a decoding bug — it is attention. But before concluding a kernel is broken, read the model's own config fields: the layer pattern that drove this was sitting in
indexer_types, unused, while vLLM read a different field that shippednull. - A compute-capability check written for one Blackwell part (
== 10) silently excludes a newer one (SM120, major 12). Widen it. - A variable set to an empty string is not unset.
NCCL_GRAPH_FILE=""fails where no variable at all succeeds. - Correct beats fast: the dense workaround was the cheapest way to prove the weights were fine and isolate the fault to attention, even though the sparse path is the one worth shipping.
Credits
This stands on other people's work:
- 0xSero
- the REAP-pruned
GLM-5.2-504BNVFP4 checkpoint, and theglm-5.2-sm120recipe that carries the correctindex_topk_pattern. Matching that pattern is what turned looping into correct output. - voipmonitor
- the
b12xvLLM image, built forsm_120a, which is what runs the sparse kernel on the RTX PRO 6000 at all. Full tag:voipmonitor/vllm:black-benediction-b12xpr11-vllmbb6c5b7-b12xd90d89c-fi3395b41aa8d-dg324aced12c-cu132-20260608. - Cerebras
- REAP (Router-weighted Expert Activation Pruning), the method behind the 504B pruned checkpoint (arXiv:2510.13999).
Related work
Read the GLM-5.2 inference case study for the deployment constraints, validation scope, and result. For my related engineering background, see the AI and inference CV (PDF).