Qwen's agentic AgentWorld 35B-A3B (35B MoE, ~3B active) in NVFP4 on one DGX Spark, serving the full 262,144-token context at 83.1 tok/s single-stream with Qwen3.6's MTP head grafted in.
* 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
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. This recipe serves a third-party NVFP4 export of it (nvfp4-pack-quantized, group_size 16, static-minmax activation observer) on a single Spark, and the reason to bother is decode speed: 64.09 tok/s single-stream at a 2048-token prompt, against 30.4 for the same model, same node, same flags in BF16. The 2.11x is unusually large for a quant, and it is large for a legible reason worth checking on any vendor quant before you download it — the ignore list. This one has 191 entries and 189 of them are things that never run here: the 27-block vision tower and the tiny per-layer router gates. Only lm_head is a real carve-out. That means the two things that dominate bytes read per generated token — the routed experts AND the full-attention projections — are both 4-bit, which is exactly the case where a quant converts into decode rather than only into disk. Summed from the safetensors headers, a decode step reads 2.42 GB here against 5.89 GB in BF16, a byte ratio of 2.44 that lands within 13% of the measured 2.11x speed ratio. Nothing else needs explaining. Two practical notes before you copy the serve command. First, this checkpoint ships the vision tower — 333 visual.* tensors are present in the file — so it loads on stock vLLM with no patch and you must NOT pass --language-model-only, which would skip constructing a tower the weights then have nowhere to go. Second, the speedup shrinks as the prompt grows, because the quant compresses weights and not the KV cache: it is 2.12x at a 511-token prompt, 2.11x at 2,042 and 2.06x at 8,206, and by a 250,223-token prompt decode is down to 27.46 tok/s in absolute terms. If your workload is long-context repo prompts rather than chat, size your expectations off the tail of that curve, not its head.
- 1 × DGX Spark (GB10, sm_121) — no tensor parallelism, no second node
- Measured 83.08 tok/s single-stream decode at a 2048-token prompt, warm, greedy — 1.29x the same checkpoint with no draft (64.54, same node, same session) and 2.73x the model in BF16
- Speculative decoding on a model that ships no draft: Qwen3.6-35B-A3B's 19 mtp.* tensors graft in unmodified, 1.7 GB of file surgery, no training
- Loads on stock vLLM 0.24.0: no patch, no --language-model-only, no --moe-backend flag
- Full 262,144-token context served, proven with a real 261,081-token request (103.7 s to first token, 27.86 tok/s after)
- Weights 20.53 GiB; whole serving working set ~39 of 114 usable GiB, so two thirds of the Spark stays free
- A six-real-entry ignore list: experts, full attention and the linear-attention projections are all 4-bit — only lm_head, the router gates and an unused vision tower are not
- Decode step reads 2.42 GB against BF16's 5.89 GB — measured from the safetensors headers, and it explains the whole speedup
- No speculative draft available — the config advertises mtp_num_hidden_layers: 1 but the checkpoint ships zero MTP tensors
- Weight load 125 s from a single 20.4 GiB safetensors file; full startup ~4 min
Software requirements
- 1 × DGX Spark (GB10, sm_121), NVIDIA driver ≥ 580 (CUDA-13 capable — the FP4 MoE kernels require it)
- vLLM 0.24.0 with torch 2.11.0+cu130 (verified 2026-07-27). Stock, unpatched
- The `hf` CLI to fetch the checkpoint (~21.9 GB)
- ~25 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. This model needs little of it, but a stale server holding 60 GiB will still ruin the run.
pgrep -af 'vllm serve|ray::' || echo 'node is clear' ray stop --force 2>/dev/null || true free -gbash - 2
Download the checkpoint
One safetensors file, 21,901,607,200 bytes, holding 124,756 tensors. There is no model.safetensors.index.json, which is worth knowing because the usual pre-flight checks that read the weight index do not apply here — read the file's own header instead if you want to inspect it. Ours pulled at ~60 MB/s, about six minutes.
hf download sakamakismile/Qwen-AgentWorld-35B-A3B-NVFP4 \ --local-dir ~/models/hf/Qwen-AgentWorld-35B-A3B-NVFP4 # verify before serving stat -c %s ~/models/hf/Qwen-AgentWorld-35B-A3B-NVFP4/model.safetensors # expect 21901607200 find ~/models/hf/Qwen-AgentWorld-35B-A3B-NVFP4 -name '*.incomplete' | wc -l # expect 0bashA partial file fails deep into load, two minutes in. Check the byte count and the incomplete count first.
- 3
Graft in Qwen3.6's MTP head
This is the step that makes the recipe fast, and it is file surgery — no training, no GPU, about a minute and 1.7 GB of disk. Qwen ships AgentWorld without an MTP head: `text_config.mtp_num_hidden_layers` says 1, but the checkpoint holds **zero** `mtp.*` tensors and its language layers stop at 39. Qwen3.6-35B-A3B is the same architecture down to every shape-defining field — hidden 2048, vocab 248,320, 256 experts top-8, head_dim 256, 40 layers — and its head grafts in unmodified. Copy the 19 `mtp.*` tensors in, hardlink the 21.9 GB body so nothing is duplicated, flip `mtp_num_hidden_layers` to 1 and add `re:^mtp.*` to the quant config's `ignore` so the BF16 head is not read as NVFP4. The same move was first published for KAT-Coder V2.5 (see that recipe); this run confirms it transfers to a second Qwen3.6 sibling.
# needs any Qwen3.6-35B-A3B checkpoint that kept its MTP head. The Unsloth # NVFP4-Fast build does: its quant config excludes the head with `re:^mtp.*`, # so the 19 mtp.* tensors sit there in BF16. python3 - <<'PY' import json, struct, os, shutil SRC_MTP = os.path.expanduser('~/models/hf/Qwen3.6-35B-A3B-NVFP4-Fast') SRC = os.path.expanduser('~/models/hf/Qwen-AgentWorld-35B-A3B-NVFP4') OUT = os.path.expanduser('~/models/hf/Qwen-AgentWorld-35B-A3B-NVFP4-MTP') def hdr(p): with open(p,'rb') as f: n = struct.unpack('<Q', f.read(8))[0] return json.loads(f.read(n)), 8 + n os.makedirs(OUT, exist_ok=True) shard = os.path.join(SRC_MTP, 'model-00005-of-00005.safetensors') h, base = hdr(shard) mtp = {k: v for k, v in h.items() if k.startswith('mtp.')} assert len(mtp) == 19, len(mtp) order = sorted(mtp, key=lambda k: mtp[k]['data_offsets'][0]) nh, cur = {}, 0 for k in order: s, e = mtp[k]['data_offsets'] nh[k] = {'dtype': mtp[k]['dtype'], 'shape': mtp[k]['shape'], 'data_offsets': [cur, cur + (e - s)]} cur += e - s blob = json.dumps(nh).encode(); blob += b' ' * ((8 - len(blob) % 8) % 8) with open(shard,'rb') as src, open(os.path.join(OUT,'mtp.safetensors'),'wb') as dst: dst.write(struct.pack('<Q', len(blob))); dst.write(blob) for k in order: s, e = mtp[k]['data_offsets']; src.seek(base + s); rem = e - s while rem: b = src.read(min(rem, 1 << 24)); dst.write(b); rem -= len(b) body_path = os.path.join(SRC, 'model.safetensors') link = os.path.join(OUT, 'model.safetensors') if not os.path.exists(link): os.link(body_path, link) # same fs = free body, _ = hdr(body_path) wm = {k: 'model.safetensors' for k in body if k != '__metadata__'} wm.update({k: 'mtp.safetensors' for k in nh}) json.dump({'metadata': {'total_size': os.path.getsize(body_path) + os.path.getsize(os.path.join(OUT,'mtp.safetensors'))}, 'weight_map': wm}, open(os.path.join(OUT,'model.safetensors.index.json'),'w')) cfg = json.load(open(os.path.join(SRC,'config.json'))) cfg['text_config']['mtp_num_hidden_layers'] = 1 q = cfg.get('quantization_config') or cfg['text_config'].get('quantization_config') q['ignore'].append('re:^mtp.*') # REGEX, not a literal name json.dump(cfg, open(os.path.join(OUT,'config.json'),'w'), indent=2) for f in os.listdir(SRC): if f.endswith('.safetensors') or f.endswith('.index.json') or f in ('config.json','.cache'): continue s = os.path.join(SRC, f) if os.path.isfile(s): shutil.copy2(s, os.path.join(OUT, f)) PY # verify: 1,689,283,824 bytes of head, and the body is a hardlink (link count 2) stat -c '%s %h %n' ~/models/hf/Qwen-AgentWorld-35B-A3B-NVFP4-MTP/mtp.safetensors \ ~/models/hf/Qwen-AgentWorld-35B-A3B-NVFP4-MTP/model.safetensorsbashThe head is BF16 and stays BF16 — it is 1.69 GB on disk but only ~129 MB is read per drafted token, because 1.61 GB of it is 256 routed experts of which 8 fire. Quantizing it was measured on the Qwen3.6-35B-A3B parent (2026-07-28) and bought exactly zero decode; do not bother.
- 4
Serve it, with the grafted draft
Note what is NOT here: no --tensor-parallel-size, no --moe-backend, no --enforce-eager, no --quantization, no --speculative-config, and in particular no --language-model-only. vLLM auto-selects the FlashInfer CUTLASS NVFP4 MoE backend and the FLASH_ATTN attention backend on sm_121, and both work. **Point it at the grafted directory, not the download.** The only addition over the no-draft configuration is `--speculative-config`; everything else is identical, which is what makes the 64.5 → 83.1 tok/s comparison honest. Serving the plain `Qwen-AgentWorld-35B-A3B-NVFP4` directory still works and is the fallback if you do not want the extra 1.6 GiB of residency.
PATH="$HOME/venvs/vllm/bin:$HOME/.local/bin:/usr/local/cuda/bin:$PATH" \ VLLM_USE_DEEP_GEMM=0 TORCHINDUCTOR_COMPILE_THREADS=2 MAX_JOBS=4 \ vllm serve ~/models/hf/Qwen-AgentWorld-35B-A3B-NVFP4-MTP \ --served-model-name Qwen-AgentWorld-35B-A3B-NVFP4 \ --max-model-len 262144 \ --kv-cache-memory-bytes 8589934592 \ --gpu-memory-utilization 0.90 \ --max-num-seqs 4 \ --max-num-batched-tokens 8192 \ --speculative-config '{"method":"mtp","num_speculative_tokens":2}' \ --port 8000bashExpect 'Model loading took 22.11 GiB memory' (20.53 without the draft) and 'GPU KV cache size: 366,382 tokens' (412,980 without). The draft costs 1.58 GiB of residency and 11% of the KV pool, and at an 8 GiB pool that still leaves 1.40x the full context for one user.
- 5
Confirm what it actually chose
Four lines tell you the run is healthy. If the MoE backend line says EMULATION, or the KV pool is far off 412,980 tokens, read the troubleshooting section before benchmarking anything.
grep -E 'NvFp4 MoE backend|attention backend|Model loading took|GPU KV cache size|Maximum concurrency' /tmp/vllm.logbashExpect: FLASHINFER_CUTLASS, FLASH_ATTN, 20.53 GiB, 412,980 tokens, 1.58x.
- 6
Warm it up before you trust any number
Expert planes fault in from NVMe on first touch and the FlashInfer NVFP4 kernels JIT on first use. 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 64.73 and 64.09 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-AgentWorld-35B-A3B-NVFP4", "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 ~43 GiB used of 121 — weights 20.5 + KV 8 + overhead ~10.5 + OS ~4bash
Key vLLM parameters
| Parameter | Value | Purpose |
|---|---|---|
| --language-model-only | NOT set (important) | Do not pass it, even though config.json declares language_model_only: true. That key is inherited metadata and vLLM never reads it. This checkpoint genuinely ships the vision tower — 333 visual.* tensors are in the file — so the tower must be constructed for the weights to have somewhere to land. Passing the flag on a vLLM whose qwen3_5.py carries a tower-skipping guard produces 'ValueError: There is no module or parameter named visual'. The reliable check is the checkpoint, not the config or the architecture name. |
| --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 after the download. |
| --kv-cache-memory-bytes, on quantizing the weights | unchanged from BF16 — do not shrink it | The pool is the same 412,980 tokens from the same 8 GiB pin whether the weights are 4-bit or 16-bit, because KV cost is layer geometry (10 full-attention layers, 2 KV heads, head_dim 256) and has nothing to do with weight precision. The checkpoint's quant config carries no kv_cache_scheme, so nothing about the cache is quantized. Do not reason 'the weights got 3x smaller, so the cache budget can shrink too'. |
| --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. On this architecture only 10 of 40 layers hold KV, so context is cheap — do not inherit a smaller value from another config. |
| --max-num-seqs | 4 | Left at 4 rather than dropped to 1-2. There is no speculative draft here, so nothing derives a CUDA-graph capture size from it and it is a pure scheduler knob — but the KV it could claim is headroom we are not using anyway, so lowering it would buy nothing while risking the decode regression this flag causes on drafted configs. |
| --max-num-batched-tokens | 8192 | Sets the prefill chunk. Measured prefill climbs from 3,966 tok/s on a 511-token prompt to 7,765 tok/s on an 8,206-token one and holds 6,424 tok/s at 32,776, which is the regime where TTFT is decided. The 262,144-token request completed at this setting, so it does not need lowering for long prefills on this model. |
| --moe-backend | not set (deliberately) | Leave it alone. On vLLM 0.24.0 the engine picks FLASHINFER_CUTLASS out of ['FLASHINFER_TRTLLM', 'FLASHINFER_CUTEDSL', 'FLASHINFER_CUTEDSL_BATCHED', 'FLASHINFER_CUTLASS', 'VLLM_CUTLASS', 'MARLIN', 'EMULATION'] and it serves correctly. Note this is version-specific: on vLLM 0.26.0 FLASHINFER_CUTLASS is no longer offered for NVFP4 MoE on GB10 and asking for it explicitly is a hard startup failure, so if you move engine versions, re-read the chosen-backend line and re-measure rather than pinning a backend by name. |
| --quantization | not set (deliberately) | The checkpoint's config.json carries a compressed-tensors quantization_config with format nvfp4-pack-quantized, so vLLM detects the scheme itself. Passing a --quantization flag here can only disagree with the file. |
| --speculative-config | {"method":"mtp","num_speculative_tokens":2} | The single biggest lever on this recipe, and it exists only because the draft was grafted in — Qwen ships AgentWorld with no MTP tensors. Measured this session on one node, warm, greedy, concurrency 1: **64.54 → 83.08 tok/s at a 2048-token prompt (+28.7%)**, 65.29 → 86.12 at 512 (+31.9%), and 78.39 at 8,192 against 61.74 for the no-draft arm published the day before on the same node and flags (+27.0%). Acceptance is 77.1-79.5% on a fixed 3-prompt set across two boots, i.e. 2.54-2.59 tokens per step. Because verification is distribution-preserving the draft cannot change what the model outputs; the only thing at risk is speed. |
| num_speculative_tokens (k) | 2 (the measured peak) | Swept 0/1/2/3 in one session on Spark-1, warm, greedy, concurrency 1, 2048-token prompt: k=0 **64.54** · k=1 **78.99** · k=2 **81.99 / 83.08** (two boots) · k=3 **75.82** tok/s. The peak is bracketed on both sides rather than assumed, and k=3 — the loser one step past it — is published so you do not have to re-run it. Acceptance falls monotonically as k rises (87.5% at k=1, 77.1-79.5% at k=2, 69.3% at k=3, fixed 3-prompt set), while mean accepted *length* keeps climbing (1.88 → 2.55 → 3.08 tokens/step) — the classic trap: do not optimize on accepted length, optimize on tok/s. |
| --enforce-eager | not set (deliberately) | Single node, so there is no cross-node cudagraph-replay deadlock to avoid, and transients are nowhere near tight at a 39 GiB working set. Engine profile, KV-cache creation and warmup took 96.9 s total including 39.0 s of compilation, and the captured graphs are worth keeping. |
| --tensor-parallel-size | not set (1 node) | The working set is ~39 of 114 usable GiB. Splitting across two Sparks would add RoCE/NCCL coordination, the per-node NCCL_IB_GID_INDEX resolution and the Gloo interface binding in exchange for memory that is nowhere near scarce. |
API usage
Chat completion
The model emits a 'Thinking Process:' preamble by default. Suppress it by passing chat_template_kwargs: {"enable_thinking": false} in the request body.
curl -s http://127.0.0.1:8000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "Qwen-AgentWorld-35B-A3B-NVFP4",
"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 — but the habit is what keeps the measurement comparable against a drafted build, where frame counting under-reports by the acceptance length.
import json, time, urllib.request, uuid
body = {
"model": "Qwen-AgentWorld-35B-A3B-NVFP4",
"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")pythonRead the per-token byte budget out of the checkpoint
This is the check that predicts whether a vendor quant will actually speed decode up, and it costs a header read rather than a benchmark. It works on this checkpoint even though it has no weight index, because safetensors puts its JSON header at the front of the file. Here it prints 2.42 GB/token; the same script over the BF16 release prints 5.89.
import json, struct, collections
PATH = "/home/you/models/hf/Qwen-AgentWorld-35B-A3B-NVFP4/model.safetensors"
TOPK, NUM_EXPERTS, BANDWIDTH = 8, 256, 273e9
with open(PATH, "rb") as f:
hdr = json.loads(f.read(struct.unpack("<Q", f.read(8))[0]))
hdr.pop("__metadata__", None)
cat = collections.Counter()
for name, meta in hdr.items():
lo, hi = meta["data_offsets"]
if "visual" in name: c = "visual"
elif "experts" in name and "shared" not in name: c = "routed_experts"
elif "embed_tokens" in name: c = "embed"
else: c = "per_token_dense"
cat[c] += hi - lo
# a decode step reads everything except the vision tower and the embedding gather,
# and only TOPK of NUM_EXPERTS routed experts
per_token = cat["per_token_dense"] + cat["routed_experts"] * TOPK / NUM_EXPERTS
print(f"{per_token/1e9:.2f} GB/token -> ceiling {BANDWIDTH/per_token:.0f} 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
- 'ValueError: There is no module or parameter named visual in Qwen3_5MoeForConditionalGeneration' at weight loading.
- You passed --language-model-only on a vLLM whose qwen3_5.py has been patched with a tower-skipping guard (a patch some language-only checkpoints on this architecture require). This checkpoint is not one of them: it ships all 333 visual.* tensors, so the tower must be built. Drop the flag. The general rule for this architecture family is that the tower decision is a property of the individual checkpoint, not of the architecture — count the visual tensors in the checkpoint before writing the serve line. The mirror-image failure, on a checkpoint that lacks the tower, is 'Following weights were not initialized from checkpoint: {visual.…}'.
- How do I count the visual tensors when there is no model.safetensors.index.json?
- Read the safetensors header directly. The first 8 bytes of the file are a little-endian u64 giving the header length; the next that-many bytes are JSON keyed by tensor name. You can do it over HTTP with two range requests without downloading the weights: `curl -r 0-7` for the length, then `curl -r 8-<n+7>` for the header. This checkpoint's header is 17,583,240 bytes and names 124,756 tensors, 333 of them under model.visual.
- Decode is around 64 tok/s. Should a 4-bit 35B-A3B be faster than that?
- No — that is close to what the memory bus allows. Decode on the Spark is bandwidth-bound. Summing the safetensors header and weighting the routed experts by top-8-of-256, a decode step here reads 2.42 GB, which puts the arithmetic ceiling at roughly 113 tok/s against 273 GB/s; 64.09 is 57% of it, a normal fraction once NVFP4 dequantization and the unquantized lm_head are accounted for. The backends chosen (FLASHINFER_CUTLASS NVFP4 MoE, FLASH_ATTN) are the right ones on sm_121.
- The quant is 3.2x smaller on disk but only 2.11x faster. Where did the rest go?
- Nowhere — the two ratios answer different questions and should not match. File size is dominated by all 256 experts; per-token bytes read count only the 8 that route. The measured per-token read goes 5.89 GB (BF16) to 2.42 GB (NVFP4), a 2.44x byte ratio, which lands within 13% of the 2.11x measured speed ratio. That residual is dequantization work and the unquantized lm_head, which is 0.95 GiB read on every single token and is not compressed at all. Always compute the byte ratio, not the file ratio, before predicting a quant's speedup.
- The speedup I measure is smaller than 2.11x.
- Check your prompt length before anything else. This quant compresses weights and not the KV cache — its quantization_config carries no kv_cache_scheme — so as the prefix grows the share of per-token bytes that are cache reads rises and the weight saving becomes a smaller fraction of the work. Measured against BF16 at matched prompt lengths on the same node: 2.12x at 511 tokens, 2.11x at 2,042, 2.06x at 8,206. In absolute terms decode falls further out: 54.89 tok/s at 32,776, 37.35 at 131,291, 27.46 at 250,223. A quant does less for a long-context workload than for a chat one, which is the opposite of the usual intuition.
- 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 the file holds 124,756 tensors and not one matches 'mtp' — the language-model 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 checkpoint, not just the config; the config is a declaration of intent and the tensor names are 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.
- Should I serve this on two Sparks with TP2 instead?
- There is no reason to. The whole working set is ~39 of 114 usable GiB on one node, so tensor parallelism would buy memory that is not remotely 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 — and if you have two Sparks, run two copies rather than one split one.
- Is this quant's output actually good? It is a third-party export.
- This recipe measures speed, not quality, and does not publish an accuracy claim for it. What can be said from serving it: generation is coherent and on-task across the benchmark prompts and hand checks, and the quantization scheme is a conservative one — nvfp4-pack-quantized at group_size 16 with a static-minmax activation observer, leaving lm_head in BF16. Before trusting it on work that matters, run an eval you care about against both this and the BF16 release through an identical pipeline and compare the delta; a quant-vs-base quality claim only means anything when both sides go through the same extraction.
Revision history
What has changed on this page since it was published, and what it measured. Newest first.
- performance
Grafted Qwen3.6-35B-A3B's MTP head into the NVFP4 checkpoint and turned on speculative decoding at k=2 — AgentWorld ships no draft of its own.
Single-stream decode at a 2048-token prompt, warm, greedy, one Spark, vLLM 0.24.0: 64.54 → 83.08 tok/s (+28.7%); 512-token prompt 65.29 → 86.12 (+31.9%). Baseline re-measured in the same session. Residency 20.53 → 22.11 GiB and the KV pool 412,980 → 366,382 tokens. Acceptance 77.1-79.5% on a fixed 3-prompt set; k swept 0-3 with the peak at k=2.
Memory budget
This is the cheap end of this architecture on a Spark. vLLM logs 'Model loading took 20.53 GiB memory' against a 21,901,607,200-byte checkpoint, so the weights take under a fifth of the 114 usable GiB and the memory question stops being interesting almost immediately. The KV side is unchanged from serving the same model unquantized, and that is worth stating plainly because it is easy to assume otherwise: KV cost is set by layer geometry, not weight precision. full_attention_interval is 4, so only 10 of the 40 layers hold a real cache, each with 2 KV heads at head_dim 256. An 8 GiB pin via --kv-cache-memory-bytes yields 412,980 tokens — 1.58x concurrency at the model's full 262,144-token window, comfortably above 1.0x with margin for the few-percent pool variation between boots. Steady-state `free -g` with the API up reads 43 GiB used of 121; subtracting the ~4 GiB idle OS baseline leaves ~39 GiB of serving working set against the 114 GiB ceiling, so roughly two thirds of the machine is still free. Weights and KV are measured from the engine log. The split of the 20.53 GiB between expert planes and dense layers is measured too — summed per tensor from the safetensors header (routed experts 16.88 GiB, everything else 3.51 GiB, of which 0.95 GiB is an unquantized lm_head, 0.95 GiB the embedding table and 0.83 GiB a vision tower this checkpoint ships and this recipe never uses). Only the overhead segment is inferred, back-computed from the steady-state total.