Qwen's agentic AgentWorld 35B-A3B (35B MoE, ~3B active) unquantized in BF16 on one DGX Spark, serving the full 262,144-token context at 30.4 tok/s single-stream.
* segment sizes marked with an asterisk are estimates pending a measured run
Eval scores
(compare all)Bench card
| 2K | 8K | 32K | 128K | |
|---|---|---|---|---|
| decode tok/s | 29.6 | 29.0 | 27.2 | 22.0 |
| ttft | 478ms | 1.58s | 7.06s | 43.18s |
| prefill tok/s | 4.2k | 5.2k | 4.6k | 3.0k |
| power | 35W | 39W | 39W | 41W |
includes 1 community run · @kvncrw
median per context · o256
Contributors
Overview
Qwen AgentWorld 35B-A3B is a 35B sparse MoE — 256 routed experts, top-8, about 3B active per token — on the Qwen3.5 hybrid attention stack: 30 of its 40 layers are gated-delta linear attention and 10 are full attention, at a 262,144-token native window. It is a language-only checkpoint despite carrying a vision_config, and at 69.3 GB of BF16 it lands on a single Spark with room to spare, which is the reason to run it unquantized: you get the model exactly as Qwen shipped it, with no quantization artifact to audit and no calibration set to defend. The price is decode speed. Every generated token re-reads roughly 3B active parameters at two bytes each, and decode on the Spark is bandwidth-bound, so 30.4 tok/s single-stream is what the memory bus allows — a 4-bit build of the same model is the lever if you want it faster, and this recipe is the honest BF16 reference it would be measured against. Two things need saying before you copy the serve command. First, stock vLLM 0.24.0 and 0.26.0 both refuse to load this checkpoint: the registered architecture is Qwen3_5MoeForConditionalGeneration, whose __init__ builds a 27-block vision tower unconditionally, and the checkpoint contains zero visual.* tensors, so weight loading dies on 113 uninitialized parameters. Passing --language-model-only is not sufficient on its own — that flag zeroes the multimodal input limits but does not skip constructing the tower — so a three-line guard in vllm/model_executor/models/qwen3_5.py is required, and it is written out in full in the steps below. Second, memory is not the binding constraint here but it is not free either: the weights alone take 64.69 GiB of a 114 GiB budget, so unlike the quantized siblings of this architecture you do not get to be careless with the KV pool. Pinning it at 8 GiB gives 1.58x concurrency at the full context; pinning it at 4 GiB, which is plenty for a 4-bit build of the same architecture, will not start.
- 1 × DGX Spark (GB10, sm_121) — no tensor parallelism, no second node
- Native BF16, exactly as Qwen published it — no quantization, no calibration, nothing to audit
- Measured 30.4 tok/s single-stream decode at a 2048-token prompt, warm, greedy
- Full 262,144-token context served, at 1.58x concurrency on an 8 GiB pinned KV pool
- Hybrid attention: 30 of 40 layers are gated-delta linear, 10 are full attention — which is why 262K context costs only 5.08 GiB of KV
- Requires a three-line vLLM patch: the vision tower is built unconditionally and this checkpoint has no visual weights
- No speculative draft available — the config advertises mtp_num_hidden_layers: 1 but the checkpoint ships zero MTP tensors
- Working set ~80 of 114 usable GiB; weights alone are 64.69 GiB
- Weight load ~140 s, full startup ~4 min from a purged page cache
Software requirements
- 1 × DGX Spark (GB10, sm_121), NVIDIA driver ≥ 580 (CUDA-13 capable)
- vLLM 0.24.0 with torch 2.11.0+cu130 (verified 2026-07-27). vLLM 0.26.0 has the identical vision-tower defect and needs the same patch
- A three-line patch to vllm/model_executor/models/qwen3_5.py — see the steps
- The `hf` CLI to fetch the checkpoint (~69.3 GB)
- ~70 GB of free disk on the serving node
Quick start
- 1
Free the node first
Anything still holding unified memory — a previous vLLM, a leftover Ray cluster — comes out of the same pool, and this checkpoint needs 64.69 GiB of it. Drop the page cache the download leaves behind as well.
pgrep -af 'vllm serve|ray::' || echo 'node is clear' ray stop --force 2>/dev/null || true python3 ~/Dev/vLLM-Moet/spark/purge-cache.py ~/models/hf free -gbash - 2
Download the checkpoint
21 safetensors shards, 69,344,334,694 bytes. Only this node needs it — there is no second rank to mirror to. Ours pulled at ~94 MB/s over the wired LAN, about 13 minutes.
hf download Qwen/Qwen-AgentWorld-35B-A3B \ --local-dir ~/models/hf/Qwen-AgentWorld-35B-A3B # verify before serving ls ~/models/hf/Qwen-AgentWorld-35B-A3B/*.safetensors | wc -l # expect 21 find ~/models/hf/Qwen-AgentWorld-35B-A3B -name '*.incomplete' | wc -l # expect 0bashA partial tree fails deep into load, several minutes in. Check the shard count and the incomplete count first.
- 3
Patch vLLM so it does not build the vision tower
This is mandatory — without it the engine dies at weight loading with 113 uninitialized visual.* parameters. Qwen3_5MoeForConditionalGeneration.__init__ constructs a 27-block Qwen3_VisionTransformer unconditionally, and this checkpoint contains none of its weights. The guard below mirrors what vLLM already does in qwen3_next.py (`text_only = mm_config is None or mm_config.language_model_only`). Keep the .orig backup so you can revert.
V=~/venvs/vllm/lib/python3.12/site-packages/vllm/model_executor/models/qwen3_5.py cp -n "$V" "$V.orig" # In class Qwen3_5MoeForConditionalGeneration.__init__, replace: # # with self._mark_tower_model(vllm_config, {"image", "video"}): # self.visual = Qwen3_VisionTransformer(...) # # with: # # if multimodal_config.language_model_only: # self.visual = None # else: # with self._mark_tower_model(vllm_config, {"image", "video"}): # self.visual = Qwen3_VisionTransformer( # config.vision_config, # norm_eps=getattr(config, "rms_norm_eps", 1e-6), # quant_config=quant_config, # prefix=maybe_prefix(prefix, "visual"), # )bashOnly the MoE class needs it. The guard is inert unless you pass --language-model-only, so it cannot affect a real multimodal Qwen3.5 checkpoint served from the same venv.
- 4
Serve it
Note what is NOT here: no --tensor-parallel-size, no --moe-backend, no --enforce-eager, no --speculative-config, no quantization flag. vLLM auto-selects the FlashInfer CUTLASS unquantized MoE backend and the FLASH_ATTN attention backend on sm_121 and both work.
PATH="$HOME/venvs/vllm/bin:$PATH" VLLM_USE_DEEP_GEMM=0 \ TORCHINDUCTOR_COMPILE_THREADS=2 MAX_JOBS=4 \ vllm serve ~/models/hf/Qwen-AgentWorld-35B-A3B \ --served-model-name Qwen/Qwen-AgentWorld-35B-A3B \ --max-model-len 262144 \ --kv-cache-memory-bytes 8589934592 \ --gpu-memory-utilization 0.90 \ --max-num-seqs 4 \ --max-num-batched-tokens 8192 \ --language-model-only \ --port 8000bash--language-model-only is what activates the patch above. Both are needed; neither works alone.
- 5
Confirm what it actually chose
Four lines tell you the run is healthy. If the KV pool is far off 412,980 tokens, or the MoE backend line says something other than FlashInfer CUTLASS, read the troubleshooting section before benchmarking.
grep -E 'MoE backend|attention backend|Model loading took|GPU KV cache size|Maximum concurrency' /tmp/vllm.logbashExpect: FlashInfer CUTLASS Unquantized MoE, FLASH_ATTN, 64.69 GiB, 412,980 tokens, 1.58x.
- 6
Warm it up before you trust any number
Expert planes fault in from NVMe on first touch. Send a few hundred tokens of real traffic after every restart, then measure. The tell that you are still cold is a 512-token prompt benchmarking slower than a 2048-token one; warm, ours run 30.5 and 30.4 tok/s respectively.
for i in 1 2 3; do curl -s http://127.0.0.1:8000/v1/chat/completions \ -H 'Content-Type: application/json' \ -d '{"model":"Qwen/Qwen-AgentWorld-35B-A3B", "messages":[{"role":"user","content":"Write an LRU cache in Python."}], "max_tokens":256,"temperature":0}' > /dev/null donebash - 7
Check the memory you actually used
Read this with the API up and idle, never during weight loading — mid-load the KV pool does not exist yet and the figure flatters you by exactly the amount you are about to allocate.
free -g # expect ~84 GiB used of 121 — weights 64.69 + KV 8 + overhead ~11 + OS ~4bash
Key vLLM parameters
| Parameter | Value | Purpose |
|---|---|---|
| --language-model-only | set (mandatory) | The checkpoint's own config.json sets language_model_only: true, but that is a checkpoint key — vLLM's MultiModalConfig.language_model_only defaults to False and is only set by this CLI flag. On its own the flag just zeroes the per-modality input limits; paired with the qwen3_5.py guard in the steps it is what stops the vision tower from being constructed. Omit either half and the engine dies at weight loading. |
| --kv-cache-memory-bytes | 8589934592 (8 GiB) | Pins the KV pool deterministically instead of nudging the utilization fraction, which is unreliable on GB10 because vLLM's utilization math counts system-wide usage. 8 GiB buys 412,980 tokens = 1.58x concurrency at the full 262,144 context. It also skips vLLM's startup free-memory check, so no page-cache purge is needed once the weights are warm. |
| --kv-cache-memory-bytes = 4 GiB | tried, rejected | 4 GiB is the value that works for 4-bit builds of this same architecture, and it does not transfer to BF16. The engine refuses to start: 'To serve at least one request with the model's max seq len (262144), 5.08 GiB KV cache is needed, which is larger than the available KV cache memory (4.0 GiB). Based on the available memory, the estimated maximum model length is 205920.' Published so nobody spends a four-minute boot rediscovering it. |
| --max-model-len | 262144 | The model's full native window, and the pool is divided to 1.58x rather than left idle at a higher multiple. Do not inherit a smaller value from another config — on this architecture only 10 of 40 layers hold KV, so context is cheap even in BF16. |
| --max-num-seqs | 4 | Left at 4 rather than dropped to 1-2. There is no speculative draft here, so nothing derives a cudagraph capture size from it and it is a pure scheduler knob — but the KV it can claim is headroom we are not using anyway, so lowering it would buy nothing and risks the decode regression that this flag causes on drafted configs. |
| --max-num-batched-tokens | 8192 | Sets the prefill chunk. At 8192 the measured prefill rate climbs from 1,608 tok/s on a 512-token prompt to 4,892 tok/s on an 8,206-token one, which is the regime where TTFT is decided. |
| --speculative-config | not set (none available) | The config advertises mtp_num_hidden_layers: 1, but model.safetensors.index.json contains zero tensors matching mtp — the draft layer was not shipped. Do not pass a {"method":"mtp"} config expecting it to work. An ngram/prompt-lookup draft would initialize on this architecture (vLLM's qwen_gdn_linear_attn path partitions the recurrent state properly, unlike bare KDA ports) but it only drafts when the output repeats the prompt, which is a loss on open-ended generation. |
| --moe-backend | not set (deliberately) | Leave it alone. vLLM auto-selects 'FlashInfer CUTLASS Unquantized MoE' out of ['FlashInfer TRTLLM', 'FlashInfer CUTLASS', 'TRITON', 'BATCHED_TRITON'] on sm_121 and it serves correctly. The marlin-only advice in older DGX Spark write-ups is about NVFP4 checkpoints on a different vLLM build and does not apply to a BF16 MoE. |
| --enforce-eager | not set (deliberately) | Single node, so there is no cross-node cudagraph-replay deadlock to avoid, and transients are not tight — the engine's profile, KV-cache creation and warmup took 65 s total including 20 s of compilation. The captured graphs are worth keeping. |
| --tensor-parallel-size | not set (1 node) | The working set is ~80 GiB, which fits one Spark. Splitting it across two would add RoCE/NCCL coordination plus the GID-index and Gloo-interface gotchas, in exchange for memory that is not scarce enough to need them. |
API usage
Chat completion
curl -s http://127.0.0.1:8000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "Qwen/Qwen-AgentWorld-35B-A3B",
"messages": [{"role": "user", "content": "Explain MoE routing in three sentences."}],
"max_tokens": 512
}' | jq -r '.choices[0].message.content'bashMeasure decode correctly
Count tokens from usage.completion_tokens, not by counting SSE frames. There is no speculative draft in this config so the two happen to agree here — but the habit is what keeps a measurement comparable against a drafted build of the same model, where frame counting under-reports by the acceptance length.
import json, time, urllib.request, uuid
body = {
"model": "Qwen/Qwen-AgentWorld-35B-A3B",
"messages": [{"role": "user",
"content": f"[{uuid.uuid4().hex[:8]}] Write an LRU cache in Python."}],
"max_tokens": 256, "temperature": 0, "stream": True,
"stream_options": {"include_usage": True}, "ignore_eos": True,
}
req = urllib.request.Request("http://127.0.0.1:8000/v1/chat/completions",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
t0 = time.perf_counter(); t_first = None; usage = None
with urllib.request.urlopen(req) as res:
for raw in res:
line = raw.decode().strip()
if not line.startswith("data: "):
continue
if line[6:] == "[DONE]":
break
ch = json.loads(line[6:])
usage = ch.get("usage") or usage
if ch.get("choices") and ch["choices"][0]["delta"].get("content") and not t_first:
t_first = time.perf_counter()
t_end = time.perf_counter()
print(f"ttft {1000*(t_first-t0):.0f} ms")
print(f"decode {(usage['completion_tokens']-1)/(t_end-t_first):.1f} tok/s")pythonConfirm the KV pool you actually got
The pool size varies a few percent between boots, so re-read it rather than assuming the number below. If it comes up smaller than --max-model-len the server refuses to start, which is the failure mode the 8 GiB pin is sized to avoid.
grep -E 'GPU KV cache size|Maximum concurrency' /tmp/vllm.log
# GPU KV cache size: 412,980 tokens
# Maximum concurrency for 262,144 tokens per request: 1.58xbashTroubleshooting
- Engine core initialization failed — 'ValueError: Following weights were not initialized from checkpoint: {visual.blocks.25.norm2.weight, visual.patch_embed.proj.weight, ...}' (113 parameters).
- This is the defining failure of this checkpoint and it happens on stock vLLM 0.24.0 and 0.26.0 alike. The registered architecture is Qwen3_5MoeForConditionalGeneration, whose __init__ builds a 27-block Qwen3_VisionTransformer unconditionally, and this is a language-only release with zero visual.* tensors in model.safetensors.index.json. Passing --language-model-only alone does NOT fix it: that flag sets MultiModalConfig.language_model_only, which zeroes the per-modality input limits but is never consulted at construction time in qwen3_5.py. Apply the three-line guard from the steps (skip the tower and set self.visual = None when language_model_only is set) AND pass the flag. vLLM already does exactly this in qwen3_next.py, so the shape of the fix is upstream's own.
- 'To serve at least one request with the model's max seq len (262144), 5.08 GiB KV cache is needed, which is larger than the available KV cache memory (4.0 GiB).'
- Your --kv-cache-memory-bytes is too small for BF16. A 4 GiB pool is enough for a 4-bit build of this architecture but not for this one — the same 262,144-token window costs 5.08 GiB here. Use 8589934592 (8 GiB), which yields 412,980 tokens and 1.58x concurrency. Do not solve it by lowering --max-model-len; the message helpfully suggests 205,920 tokens, but memory is not scarce enough on this node to give up a quarter of the context window for it.
- Decode is only ~30 tok/s. Is a kernel misconfigured?
- No — that is what BF16 costs on this hardware. Decode on the Spark is bandwidth-bound, and each generated token re-reads roughly 3B active parameters at two bytes each. The measured 30.4 tok/s at a 2048-token prompt is a normal fraction of the resulting roofline, and the backends chosen (FlashInfer CUTLASS unquantized MoE, FLASH_ATTN) are the right ones. If you want this model faster, the lever is a 4-bit expert quantization, not a flag — and be aware that on a high-sparsity MoE a quant which leaves attention unquantized buys much less decode than its file-size reduction suggests, because attention can dominate per-token bytes read even when the experts dominate the file.
- Can I turn on speculative decoding? The config says mtp_num_hidden_layers: 1.
- No draft ships with this checkpoint. The config advertises an MTP layer but model.safetensors.index.json holds 693 tensors and not one of them matches 'mtp' — the layer indices run 0-39 with nothing at index 40, where a native MTP layer would sit. Passing '{"method":"mtp"}' will not find weights to load. Always grep the index, not just the config; the config is a declaration of intent and the index is the fact.
- The log says 'Setting attention block size to 1056 tokens to ensure that attention page size is >= mamba page size'.
- Expected on this architecture, not a warning to act on. Thirty of the forty layers are gated-delta linear attention carrying a per-sequence recurrent state, whose page has to fit alongside the full-attention pages — which forces the unusually large block size. It is part of why per-token KV lands where it does.
- 'free memory less than desired GPU memory utilization' at startup.
- Page cache from the 69 GB download is squatting in the unified pool; `free -g` showing a large buff/cache figure with little free is the signature. Run `python3 ~/Dev/vLLM-Moet/spark/purge-cache.py ~/models/hf`. Note this only applies on a boot where vLLM is auto-sizing the pool — --kv-cache-memory-bytes skips that check entirely, so once you pin KV the purge buys nothing and costs a full re-read of the 69 GB tree from NVMe.
- Should I serve this on two Sparks with TP2 instead?
- There is no reason to. The whole working set is ~80 of 114 usable GiB on one node, so tensor parallelism would buy memory that is not scarce while adding the RoCE fabric setup, the per-node NCCL_IB_GID_INDEX resolution, the Gloo interface binding and the cross-node cudagraph deadlock that forces --enforce-eager. Keep it single-node.
- Serving a genuinely multimodal Qwen3.5 checkpoint from the same venv after applying the patch.
- It still works. The guard is conditional on MultiModalConfig.language_model_only, which defaults to False and is only set by the --language-model-only CLI flag — so a serve that does not pass that flag takes the original code path and builds the tower exactly as before. Keep the .orig backup anyway; every node has its own venv, so a patch applied on one node does not exist on the other.
Memory budget
This recipe serves the model at its native precision — no quantization step, no calibration, no self-built artifact. That decision is what sets the whole memory shape. The checkpoint is 69,344,334,694 bytes of BF16 safetensors, and vLLM logs 'Model loading took 64.69 GiB memory' — so roughly 57% of a Spark's 114 usable GiB is gone before any KV is allocated. What makes that affordable is the architecture: full_attention_interval is 4, so only 10 of the 40 layers hold a real KV cache, and those carry just 2 KV heads at head_dim 256. vLLM sizes a full 262,144-token request at 5.08 GiB of KV — measured, because the first boot at a 4 GiB pin refused to start and said so in as many words. Pinning the pool at 8 GiB with --kv-cache-memory-bytes yields 412,980 tokens, which is 1.58x concurrency at the model's full native context: comfortably above 1.0x with margin for the few-percent pool variation between boots, and not so far above it that a single user is paying for concurrency they cannot spend. Steady-state `free -g` with the API up and idle reads 84 GiB used of 121; subtracting the ~4 GiB idle OS baseline leaves ~80 GiB of serving working set against the 114 GiB ceiling, so about 34 GiB stays free. The weights figure and the KV figure are measured from the engine log; the split of the 64.69 GiB between expert planes and dense layers is apportioned from parameter counts rather than measured per-tensor, and the overhead segment is back-computed from the steady-state total.