GLM-5.3-Flash (NVFP4) on 4× DGX Spark — Multi-Node TP4 Benchmark (5.3–7.0 tok/s)

#5
by tvearl - opened

tl;dr: Got GLM-5.3-Flash (320B/18B active MoE) running across 4 DGX Sparks using tensor parallelism over Ethernet (no NVLink). Decode throughput: 5.3–7.0 tok/s, TTFT ~200ms, with 131K max context. Required fixing 5 vLLM bugs along the way. Full reproduction steps below.

Hardware

Spec
Nodes 4× NVIDIA DGX Spark
GPU GB10 (sm_121), 128 GB unified memory per node
Interconnect 1 GbE cluster subnet (192.168.88.0/24), no NVLink/MNNVL
NCCL transport Socket (falls back from NVLink automatically)
OS Ubuntu (aarch64), Docker 29.2.1

Model & Config

Value
Model LibertAIDAI/GLM-5.3-Flash-NVFP4
Quantization Weight-only NVFP4 (~181 GB, 120 safetensor shards)
Architecture glm5_next — hybrid sparse + linear attention, MoE (320B total / 18B active)
Framework vLLM v0.1.dev20051+g487ecf187 (GLM-5.3-Flash branch)
Tensor parallelism TP4 across 4 nodes via external_launcher backend
max_model_len 131,072
kv_cache_dtype fp8
MoE backend marlin (critical — see Bug #1 below)
CUDA graphs Disabled (enforce_eager=True)
All-reduce PYNCCL over Socket transport

Performance Results

Throughput (post-warmup, JIT compiled)

Metric Value
TTFT (7-token prompt, 1 output token) 0.19–0.21s median
Decode (8-token prompt → 200 tokens) 5.3–7.0 tok/s (best: 7.0, median: 6.4)
Decode (2K-token prompt → 129 tokens) 5.7 tok/s
Prefill throughput (2K prompt) 89–324 input tok/s

Memory

Metric Value
Model footprint 45.13 GiB per node
Available KV cache 40.98 GiB per node
KV cache capacity 6,325,216 tokens (48× concurrent 131K requests)
Engine load time 560s (163s local SSD, ~5 min over NFS)

Quality Spot-Check

Task Result
Math (∫x² dx) Correct: x³/3 + C with power rule explanation
Code (palindrome) Correct: clean Python function with case/punctuation handling
Reasoning (syllogism) Partial — generated section headers but incomplete reasoning
Multilingual (translation) Degenerated — repetitive output loop, did not translate

The multilingual degeneration is likely an NVFP4 quantization artifact at temperature=0. Math and code quality are solid.

Bugs Fixed (5 total)

Getting TP4 working on DGX Spark required fixing several vLLM issues:

Bug 1: FLASHINFER_CUTLASS MoE backend produces garbage on GB10

The default NvFp4 MoE backend selection picks FLASHINFER_CUTLASS, which silently produces garbage output on GB10 (sm_121). No error, no crash — just wrong answers.

Fix: Force moe_backend="marlin". The Marlin backend does FP4 weight-only decompression correctly on GB10, though with a performance penalty ("Your GPU does not have native support for FP4 computation").

Bug 2: external_launcher backend required for multi-node

vLLM's standard mp (multiprocessing) executor doesn't work across separate Docker containers on different hosts. The external_launcher backend reads RANK, LOCAL_RANK, WORLD_SIZE, MASTER_ADDR, MASTER_PORT from environment and uses torch.distributed for coordination.

Fix: Use distributed_executor_backend="external_launcher" with torchrun-compatible environment variables set per container.

Bug 3: vllm serve incompatible with external_launcher

The vllm serve CLI and AsyncLLM engine don't work with the external_launcher backend — they hang waiting for HTTP server initialization that never happens on non-zero ranks.

Fix: Use the synchronous LLM class directly in a Python script. All ranks must call llm.generate() with identical prompts for deterministic TP coordination.

Bug 4: Docker image has vllm CLI as entrypoint

The Docker image glm53:sm121-v8 has ENTRYPOINT ["vllm"], so docker run ... image script.py tries to parse the script as a vLLM CLI argument.

Fix: --entrypoint python3 in the docker run command.

Bug 5: SHM Broadcast MessageQueue deadlock (the tricky one)

vLLM's parallel_state.py creates a GroupCoordinator with use_message_queue_broadcaster=True for the TP group. This creates ZMQ XPUB/SUB sockets for fast intra-process communication. The socket binds to get_ip(), which inside Docker with --network host returns the WiFi IP (10.168.168.x) instead of the cluster subnet (192.168.88.x). Remote readers on other Sparks can't connect, causing a deadlock in wait_until_ready().

Fix: Monkey-patch GroupCoordinator.__init__ to force use_message_queue_broadcaster=False:

import vllm.distributed.parallel_state as ps
_orig = ps.GroupCoordinator.__init__
def _patched(self, *a, **kw):
    kw["use_message_queue_broadcaster"] = False
    _orig(self, *a, **kw)
ps.GroupCoordinator.__init__ = _patched

This falls back to torch.distributed.broadcast_object_list via CPU/Gloo, which correctly uses GLOO_SOCKET_IFNAME=enp1s0f0np0 (the cluster interface).

Full Reproduction

Prerequisites

  • 4× DGX Spark on the same subnet
  • Docker with --gpus all support
  • vLLM Docker image with GLM-5.3-Flash support and sm_121 (GB10) CUDA kernels
  • Model weights downloaded to one node, shared via NFS to others

Docker command (per node)

# Adjust RANK (0-3) and container name per node
sudo docker run -d \
  --name glm53-node${RANK} \
  --gpus all --network host --ipc host --cap-add SYS_PTRACE \
  --entrypoint python3 \
  -e VLLM_ENABLE_V1_MULTIPROCESSING=0 \
  -e RANK=${RANK} -e LOCAL_RANK=0 -e WORLD_SIZE=4 \
  -e MASTER_ADDR=192.168.88.11 -e MASTER_PORT=29500 \
  -e NCCL_SOCKET_IFNAME=enp1s0f0np0 -e NCCL_DEBUG=WARN \
  -e GLOO_SOCKET_IFNAME=enp1s0f0np0 \
  -v /var/tmp/glm-5.3-flash-nvfp4:/var/tmp/glm-5.3-flash-nvfp4:ro \
  -v /var/tmp/glm_test.py:/workspace/glm_test.py:ro \
  glm53:sm121-v8 /workspace/glm_test.py

Important: Do NOT use --runtime nvidia on DGX Spark — only --gpus all works.

Inference script (glm_test.py)

"""GLM-5.3-Flash TP4 — multi-node inference on DGX Spark."""
import os, time
RANK = int(os.environ.get("RANK", 0))
WORLD_SIZE = int(os.environ.get("WORLD_SIZE", 1))

# Disable ZMQ MessageQueue — deadlocks multi-node external_launcher
import vllm.distributed.parallel_state as ps
_orig = ps.GroupCoordinator.__init__
def _patched(self, *a, **kw):
    kw["use_message_queue_broadcaster"] = False
    _orig(self, *a, **kw)
ps.GroupCoordinator.__init__ = _patched

from vllm import LLM, SamplingParams

llm = LLM(
    model="/var/tmp/glm-5.3-flash-nvfp4",
    tensor_parallel_size=WORLD_SIZE,
    distributed_executor_backend="external_launcher",
    max_model_len=131072,
    gpu_memory_utilization=0.78,
    kv_cache_dtype="fp8",
    block_size=2304,
    trust_remote_code=True,
    enforce_eager=True,
    moe_backend="marlin",
)

# All ranks must call generate() with identical prompts
prompt = "What is 2+2? Answer in one word:"
outputs = llm.generate([prompt], SamplingParams(temperature=0, max_tokens=10))
print(f"[RANK={RANK}] {outputs[0].outputs[0].text!r}")

Limitations & Notes

  • No NVLink: DGX Spark nodes communicate via Socket NCCL over 1 GbE. This is the primary throughput bottleneck — each decode step requires an all-reduce across all 4 nodes.
  • Marlin MoE backend: GB10 doesn't have native FP4 compute, so the Marlin backend does FP4→FP16 decompression. A native FP4 backend would be faster.
  • No CUDA graphs: enforce_eager=True because CUDA graph capture isn't supported with this architecture on GB10 yet. CUDA graphs would significantly improve decode throughput.
  • Quantization artifacts: NVFP4 quantization shows degeneration on some tasks (multilingual, complex reasoning). FP8 or unquantized weights would produce better quality.
  • JIT compilation on first run: The first inference takes ~15s due to TileLang and Triton kernel compilation. Subsequent runs are ~0.19s TTFT.

What's Next

  • Test with longer context lengths (32K, 65K, 131K)
  • Try FP8 quantization for quality comparison
  • Benchmark with CUDA graphs once supported
  • Compare with single-node TP1 (18B active fits in one 128GB node)

Tested 2026-08-27 on 4× DGX Spark (GB10, 128GB). Built image from vLLM GLM-5.3-Flash branch with sm_121 CUDA support.

LibertAI org

Your first bug report ("FLASHINFER_CUTLASS MoE backend produced silent garbage on
GB10") was the key that unblocked us, thank you. We chased it to a root cause, and
it is worth knowing that it is not GB10-specific.

vLLM's ModelOptNvFp4FusedMoE registers w13_input_scale as
PerTensorScaleParameter(data=torch.empty(...)) and expects the checkpoint to fill
it. This checkpoint is weight-only NVFP4 ("input_activations": null, zero
input_scale tensors), so it stays at its uninitialised value, observed as 0.0.
That makes g1_alphas = weight_scale_2 * 0 = 0, so every expert output is
multiplied by zero and the model runs on attention plus the shared expert alone.

Marlin works because it dequantises the weights and runs a plain bf16 GEMM, so it
never consumes an activation scale and never reaches that path. The trigger is the
checkpoint rather than the architecture, so we would expect the same on your GB300s
and on datacenter Blackwell with a weight-only NVFP4 checkpoint.

A standalone harness driving flashinfer.fused_moe.cutlass_fused_moe directly on
GB10 shows the kernel itself is correct when fed properly (cos 0.9969 against an
fp32 reference), so flashinfer is not at fault.

Filed as vllm-project/vllm#54189. Harness and analysis:
https://github.com/Libertai/glm53-flash-vllm-gb10

LibertAI org

Your bug report is what led us to the root cause, so it seems right to bring the follow-up back here: there is now a fix on the checkpoint side, which should let you delete the marlin line from that config.

RedHat published their own NVFP4 of this model on 2026-08-28 (llm-compressor). Their weight recipe is the same as ours — g16, symmetric, tensor_group, FP8-E4M3 block scales — so we rebuilt ours into the compressed-tensors layout without re-quantizing: --revision compressed-tensors. That layout never enters ModelOptNvFp4FusedMoE, so there is no uninitialised activation scale to fold and nothing to work around.

Two things specific to your run:

  • MoE backend. On that branch you should be able to go back to the FP4 kernels. marlin dequantises to BF16, so right now you are paying for 4-bit storage and getting BF16 GEMMs.
  • MTP layer. Layer 45 is FP8 block-128 there rather than FP4. RedHat excludes it deliberately and we now agree: it is the speculative drafter, and quantisation error there costs acceptance rate, which multiplies through every token rather than costing a fixed fraction of accuracy. Relevant if you try --speculative-config '{"method":"mtp","num_speculative_tokens":5}'.

I would not expect either to move your 5.3–7.0 tok/s much — TP4 over 1 GbE with NCCL on socket transport looks like the actual wall, and the MoE kernel is downstream of that. Worth saying we have not measured your topology, so that is inference, not a result.

Caveats, plainly: the branch is verified numerically only (index integrity, partition, tensor-name parity with RedHat's index, mean round-trip cosine 0.998 vs the BF16 source). No engine has loaded it yet, and there are no task evals. main is untouched. If you do try it, a yes/no on whether flashinfer_cutlass produces coherent output without marlin is the single most useful thing anyone could report.

LibertAI org

Two corrections to my earlier reply, since you are running this in a real deployment.

1. The compressed-tensors branch is not ready. I suggested you could drop marlin and use it. We tried exactly that on our own 4x RTX PRO 6000 box today and vLLM crash-looped on load (exit 1, glm53-flash-x86_64-cu130 image). Traceback not yet captured, cause not yet known — the image does support NVFP4-A16 MoE and the config parses, so it is failing later, in weight loading or MoE init. Please treat it as experimental.

2. Good news for you specifically: your marlin config was protecting you from a second bug. The input_scale = 1.0 placeholders we shipped on main are wrong — the underflow bound is per 16-element block (input_scale <= amax_block / 0.1), not per tensor, so at 1.0 every activation block with amax below 0.1 is flushed to zero. That produces input-dependent repetition which worsens with context, reported on GB300 in #7 and reproduced on our own sm_120 box. marlin dequantises the weights and never reads an activation scale, so you were immune. If you ever switch away from marlin, re-download model-input-scales.safetensors from main first — it now carries real per-projection calibrated values (median 1.58e-03; our placeholder was 632x that).

Sign up or log in to comment