T-Bank's agentic-search T-Search (36B MoE, ~3B active) in NVFP4 on one DGX Spark, with the checkpoint's own MTP head speculating 3 tokens ahead — 62.0 tok/s single-stream, 1.68x the no-draft baseline, at the full 262,144-token context. Re-verified unchanged on vLLM 0.26.0.
* 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
T-Search is an agentic retriever: T-Bank post-trained Qwen3.6-35B-A3B on synthetic multi-step search tasks in English and Russian, and it is built to drive a ReAct loop that issues corpus queries, reads snippets, keeps a coverage state across rounds and returns a ranked evidence set. Its publisher is explicit that the checkpoint is not a general-purpose upgrade over its base — what it is good at is search. That makes it a natural single-node Spark workload, because agentic retrieval means long prompts, many turns, and one user waiting on each one. The configuration below is shaped by three properties of the checkpoint, and none of them required a decision on our part. First, t-tech ships its own NVIDIA ModelOpt NVFP4 export, 23.75 GB against 69.7 GB of BF16, and vLLM recognises it as modelopt_fp4 with no flags. That 4-bit cut is routed-experts-only — the 869-entry exclude_modules list keeps every attention block, every linear-attention block, the shared experts and the embeddings in BF16 — so it should be read as a memory win first and a speed win second. Second, the same repo ships a real MTP head: 785 mtp.* tensors in a separate model-mtp.safetensors that the checkpoint's own weight index references, so a single --speculative-config line pointing back at the model directory is the whole of speculative decoding here. It is worth 1.63x. Third, only 10 of the 40 layers run full attention, those layers carry 2 KV heads, and the checkpoint asks for an FP8 KV cache — so the full 262,144-token context is not something you fight for on this model, it is something you are handed. An 8 GiB pool holds two and a half of them, and a 250,222-token request was actually sent to confirm it. The net shape is a model that occupies about 42 GiB of a 114 GiB node while serving its whole native context at 61.7 tok/s. There is no tensor parallelism, no patched engine, and no second Spark.
- 1 × DGX Spark (GB10, sm_121) — no tensor parallelism, no second node, no engine patch, on vLLM 0.24.0 and 0.26.0 alike
- Measured 62.0 tok/s single-stream decode at a 2048-token prompt, warm, greedy (vLLM 0.26.0, 2026-07-28; 61.7 on 0.24.0 the day before)
- 1.68x over the same node's 36.8 tok/s no-draft baseline, measured back to back in one session, from the MTP head that ships inside the checkpoint
- Engine-version safe: 0.24.0 → 0.26.0 moved decode 61.7 → 62.0 and the baseline 37.8 → 36.8 — both inside this machine's run-to-run spread, with no flag changes needed
- Draft acceptance actually improved on 0.26.0 — 0.652 → 0.700 overall, per-position 0.86 / 0.70 / 0.55 — without moving decode, so the extra accepted tokens are paid back in slower steps
- k=3 is the peak: k=4 measured 52.94 tok/s on an identical node, a 16% regression
- Swept k=0…5: 37.78 / 55.47 / 58.15 / 61.72 / 52.94 / 51.26 tok/s — a clean peak at 3, published with the losing values
- Full 262,144-token context served, at 2.63x concurrency on an 8 GiB pinned KV pool
- Hybrid attention — 30 of 40 layers are gated-delta linear, 10 are full attention with 2 KV heads — plus an FP8 KV cache the checkpoint asks for itself
- ~42 GiB of working set on a 114 GiB node: 23.81 GiB of weights + draft, 8 GiB of KV, the rest transients
- The 4-bit cut is routed-experts-only; attention, the linear-attention blocks, the shared experts, the embeddings and the MTP head are all BF16
- Measured at length: 60.94 tok/s at a 32K prompt, 54.48 at 131K, 47.45 at a 250,222-token prompt — the full window was actually exercised
Software requirements
- 1 × DGX Spark (GB10, sm_121), NVIDIA driver ≥ 580 (CUDA-13 capable)
- vLLM 0.26.0 with torch 2.11.0+cu130 — last verified 2026-07-28 on this version; originally built and measured on 0.24.0, which serves it identically. CUDA 13 is required for the NVFP4 MoE kernels; a cu129 build will not run them
- No vLLM patch is needed for this checkpoint, on either 0.24.0 or 0.26.0
- The `hf` CLI to fetch the checkpoint (~25.4 GB)
- ~26 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 121 GiB pool this serve draws on.
pgrep -af 'vllm serve|ray::' || echo 'node is clear' ray stop --force 2>/dev/null || true free -gbash - 2
Download the checkpoint
Two weight files: a single 23.75 GB model.safetensors and a 1.69 GB model-mtp.safetensors holding the speculative head. Both are referenced by model.safetensors.index.json, so vLLM finds the draft at the same path as the target — there is no separate draft repo to fetch.
hf download t-tech/T-Search-NVFP4 \ --local-dir ~/models/hf/T-Search-NVFP4 # verify before serving ls -la ~/models/hf/T-Search-NVFP4/model*.safetensors find ~/models/hf/T-Search-NVFP4 -name '*.incomplete' | wc -l # expect 0bashOurs pulled at ~89 MB/s over the wired LAN, about five minutes. A partial tree fails several minutes into weight loading, so check the incomplete count first.
- 3
Do NOT pass --language-model-only
This checkpoint really is multimodal: model.safetensors.index.json contains 333 model.visual.* tensors for the 27-block vision tower, and vLLM's Qwen3_5MoeForConditionalGeneration builds that tower and loads them. If your vLLM install carries the `self.visual = None` guard that language-only siblings of this architecture need, passing the flag will skip the tower and then fail on weights that have nowhere to go. Confirm which kind of checkpoint you have by counting the tower's tensors, not by looking for a vision_config — every model in this family has one.
python3 - <<'PY' import json wm = json.load(open('/home/'+__import__('os').environ['USER']+'/models/hf/T-Search-NVFP4/model.safetensors.index.json'))['weight_map'] print('visual tensors:', sum('visual' in k for k in wm)) # 333 -> serve WITHOUT --language-model-only print('mtp tensors :', sum(k.startswith('mtp.') for k in wm)) # 785 -> a real MTP draft ships PYbash - 4
Serve it, with the MTP head at k=3
The speculative config points back at the model directory itself — the MTP weights live in the same tree. vLLM resolves the draft as Qwen3_5MoeMTP and shares the target's embeddings and lm_head with it. Spell the method "mtp"; on 0.26.0 the older "qwen3_5_mtp" still loads but logs 'method `qwen3_5_mtp` is deprecated and replaced with mtp'. No MoE or attention backend flag is needed — auto-selection is correct on sm_121, though the NVFP4 MoE backend it names differs by engine version (FLASHINFER_CUTLASS on 0.24.0, VLLM_CUTLASS on 0.26.0); attention is FLASHINFER on both.
export PATH="$HOME/venvs/vllm-026/bin:$PATH" export VLLM_USE_DEEP_GEMM=0 vllm serve ~/models/hf/T-Search-NVFP4 \ --served-model-name t-search \ --max-model-len 262144 \ --kv-cache-memory-bytes 8589934592 \ --gpu-memory-utilization 0.90 \ --max-num-seqs 4 \ --max-num-batched-tokens 8192 \ --port 8000 \ --speculative-config '{"method":"mtp","model":"~/models/hf/T-Search-NVFP4","num_speculative_tokens":3}'bashFirst boot is 4-6 minutes on a warm page cache — 146-206 s of it is weight loading, and it varies boot to boot on the same node. Expect 'Model loading took 23.81 GiB memory' and 'GPU KV cache size: 689,341 tokens' with the draft (22.24 GiB / 812,849 tokens without it). Both figures were identical on 0.24.0 and 0.26.0.
- 5
Warm it before you believe any number
Expert planes fault in from NVMe on first touch and speculative decoding JITs its own Triton kernels on first use, so a cold measurement can be 20% low and — worse — can reverse a tuning conclusion. Send a few hundred tokens of real traffic, then measure. The tell that you are still cold is decode rising as the prompt gets longer instead of falling.
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":"t-search","messages":[{"role":"user","content":"Write a long explanation of how an inverted index works."}],"max_tokens":400,"temperature":0}' \ -o /dev/null donebash - 6
Confirm the draft is actually being accepted
A speculative draft that emits garbage is safe — verification rejects all of it — so the only symptom is decode quietly sinking to or below the no-draft baseline. Always read the counters rather than trusting the tok/s.
curl -s http://127.0.0.1:8000/metrics \ | grep -E 'spec_decode_num_(drafts_total|draft_tokens_total|accepted_tokens_total|accepted_tokens_per_pos_total)' \ | grep -v '^#'bashOurs reported 2531 accepted of 3882 drafted (0.652 overall), with per-position acceptance 0.841 / 0.644 / 0.471.
Key vLLM parameters
| Parameter | Value | Purpose |
|---|---|---|
| --speculative-config | {"method":"mtp","model":"<this same directory>","num_speculative_tokens":3} | The single biggest lever on this recipe: 36.83 → 62.02 tok/s at a 2048-token prompt, same node, same session, warm, 1.68x (measured 2026-07-28 on vLLM 0.26.0; 37.78 → 61.72, 1.63x on 0.24.0 the day before). `model` points at the target's own directory because the draft weights (785 mtp.* tensors) are shipped inside model-mtp.safetensors and referenced by the checkpoint's index. The draft costs a measured 1.57 GiB of residency (22.24 → 23.81 GiB) and reduces the 8 GiB KV pool from 812,849 to 689,341 tokens — both cheap at this size. Spell the method "mtp": 0.26.0 still accepts the old "qwen3_5_mtp" but logs 'method `qwen3_5_mtp` is deprecated and replaced with mtp' and resolves it to the same Qwen3_5MoeMTP draft — measured both spellings on 0.26.0, identical load (23.81 GiB), identical KV pool, decode inside noise of each other. |
| num_speculative_tokens | 3 (the measured peak) | Swept from 0 to 5 and one step past the peak. All points below are single-stream decode at a 2048-token prompt, warm, greedy, and — except k=4 — measured on the SAME node as the baseline: k=0 37.78 · k=1 55.47 · k=2 58.15 · k=3 61.72 · k=4 52.94 · k=5 51.26 tok/s. Overall draft acceptance falls as k rises (0.867 / 0.776 / 0.652 / 0.590 / 0.522 for k=1…5) while tokens per accepted step rises (1.87 / 2.55 / 2.96 / 3.36 / 3.61), and throughput is the product of the two — which is why the peak sits at 3 rather than at either end. Per-position acceptance at k=3 runs 0.841 / 0.644 / 0.471; by k=5 the last position is accepted 28.2% of the time and its extra forward pass is pure loss. Do not raise this without re-measuring. k=4 was measured on the second, identical Spark; the k=3 point was measured on both and agreed to 1.1% (62.52 vs 63.21), and every ratio quoted on this page is same-node. Re-checked at k=3 on vLLM 0.26.0 (2026-07-28): decode 62.02 tok/s and 64.13 / 59.15 at 512 / 8,192-token prompts, with overall acceptance up to 0.700 (per-position 0.865 / 0.715 / 0.563 on one boot, 0.849 / 0.681 / 0.531 on a second) — higher acceptance than 0.24.0 gave, at the same throughput. The k-sweep itself was not repeated on 0.26.0; k=3 is still the published peak on 0.24.0 evidence. |
| --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. It also skips vLLM's startup free-memory check, so no page-cache purge is needed. 8 GiB buys 689,341 tokens at k=3 = 2.63x concurrency at the full 262,144 context — more headroom than a single user can spend, so this is a knob you can turn DOWN to about 3.5 GiB if you want the memory back for something else on the node. |
| --max-num-seqs | 4 | Left at 4 deliberately, not lowered for single-user serving. Under speculative decoding vLLM derives its CUDA-graph capture sizes from max_num_seqs × (num_speculative_tokens + 1) — the engine log shows cudagraph_capture_sizes growing from [1,2,4,8] with no draft to [1,2,4,8,16,24,32] at k=3 — so shrinking it to 1 or 2 makes decode run on smaller captured graphs. The concurrency it permits is free here; the KV pool is already 2.63x oversized. |
| --language-model-only | NOT set | Deliberately omitted. Several checkpoints on this architecture are language-only releases that still carry a vision_config and need the tower suppressed; this one is not. Its index holds 333 model.visual.* tensors, the tower loads normally, and passing the flag on an engine that honours it breaks weight loading. Count the tensors, do not read the config. |
| --moe-backend / --attention-backend | NOT set (but check what auto-selection picked — it changed between engine versions) | Left to auto-selection, which serves correctly on sm_121 — but *which* backend it picks is not stable across engine versions. On vLLM 0.24.0 the log read "Using 'FLASHINFER_CUTLASS' NvFp4 MoE backend"; on 0.26.0, same checkpoint, same node, it reads "Using 'VLLM_CUTLASS' NvFp4 MoE backend" (measured 2026-07-28). Decode did not move with it (61.7 → 62.0), so this is a note, not a regression. Attention stays "Using FLASHINFER attention backend" on both. Grep the log for the two lines rather than hardcoding a backend, and treat EMULATION in that line as the only genuinely bad reading. |
| kv_cache_dtype | not set on the command line — comes from the checkpoint | The checkpoint's hf_quant_config.json declares kv_cache_quant_algo: FP8 and vLLM applies it, logging kv_cache_dtype=fp8_e4m3. That halves the per-token KV cost against a BF16 cache and is most of why 262K context is cheap here. Nothing to pass; just be aware the cache is 8-bit when comparing this pool size against another model's. |
| --max-model-len | 262144 | The model's full native window (max_position_embeddings 262,144), and it serves — a 250,222-token request was actually sent, not assumed. There is no reason to serve less: the pool holds two and a half full-length requests. Decode holds up well across that range (60.94 tok/s at a 32K prompt, 54.48 at 131K, 47.45 at 250K) but prefill does not — see the TTFT figures in troubleshooting. |
API usage
Chat completion
This model thinks out loud — replies open with a visible "Here's a thinking process:" block before the answer. That is expected, and those tokens count toward the throughput figures below.
curl -s http://127.0.0.1:8000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "t-search",
"messages": [{"role": "user", "content": "You have a corpus search tool. Plan the first three queries you would run to answer: which EU directives govern battery recycling?"}],
"max_tokens": 512
}' | jq -r '.choices[0].message.content'bashMeasure single-stream decode correctly
One request at a time. Aggregate throughput at concurrency 8 is a different metric and is not what this recipe is tuned for.
# Measure from usage.completion_tokens, never by counting SSE frames:
# speculative decoding bundles several tokens into one chunk, so frame
# counting understates decode by roughly the acceptance rate.
curl -s -o /tmp/r.json -w '%{time_total}\n' \
http://127.0.0.1:8000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"t-search","messages":[{"role":"user","content":"Explain MoE routing in detail."}],"max_tokens":256,"temperature":0}'
jq '.usage' /tmp/r.jsonbashTroubleshooting
- You are on vLLM 0.26.0 and the log says "Using 'VLLM_CUTLASS' NvFp4 MoE backend", not FLASHINFER_CUTLASS as this page used to claim.
- Expected on 0.26.0 for this checkpoint, and harmless. Auto-selection's menu of working NVFP4 MoE backends on GB10 depends on both the engine version and the checkpoint's quant layout — on 0.26.0 the same t-tech/T-Search-NVFP4 tree picks VLLM_CUTLASS where 0.24.0 picked FLASHINFER_CUTLASS, while other NVFP4 checkpoints on the same build still get FLASHINFER_CUTLASS. Measured back to back on 2026-07-28: decode 61.72 (0.24.0) vs 62.02 tok/s (0.26.0) at a 2,048-token prompt, so nothing was lost. Do not chase it with an explicit --moe-backend; only EMULATION in that line means something is actually wrong.
- Engine core initialization failed — "ValueError: There is no module or parameter named 'visual' in Qwen3_5MoeForConditionalGeneration", followed by a dump of every language_model.* parameter.
- You passed --language-model-only to an engine whose qwen3_5.py has been patched to skip building the vision tower when that flag is set — but this checkpoint ships the tower. model.safetensors.index.json contains 333 model.visual.* tensors, so the loader has 333 weights and nowhere to put them. Drop the flag. Stock vLLM 0.24.0 serves this checkpoint with no patch at all. The confusing part is that the opposite failure exists on sibling checkpoints of the same architecture — a language-only release with a leftover vision_config fails with 113 UNINITIALIZED visual.* parameters and needs exactly that flag plus that guard. The two error messages point in opposite directions; the weight index settles it in one command.
- Decode with the draft enabled is no faster than without it, or slower.
- Read /metrics before touching any flag. A draft whose logits do not match the target is harmless to correctness — verification rejects every token — so the only symptom is throughput sinking to the baseline. spec_decode_num_accepted_tokens_total must be well above zero; ours runs at 0.652 of drafted tokens. If it is zero, the draft is not the one this target was built for. If it is healthy and throughput is still flat, you are probably measuring cold: warm the server with a few hundred tokens and run again.
- Decode is FASTER at an 8192-token prompt than at 512 tokens.
- That ordering is backwards and means the run is cold — the first scenario paid for the NVFP4 expert planes faulting in from NVMe and for the speculative-decode Triton kernels JIT-compiling. Throw the pass away, send warmup traffic, and measure again. A warm run on this config falls monotonically: 64.05 → 61.72 → 59.62 tok/s across 512 / 2048 / 8192-token prompts.
- The engine logs 'CUDAGraphMode.FULL_AND_PIECEWISE is not supported with spec-decode ... setting cudagraph_mode=PIECEWISE'.
- Expected and harmless with the FlashInfer attention backend. It does mean the speculative arm runs on piecewise graphs while a no-draft baseline gets full ones, so the 1.63x published here is net of that handicap — the real speculative win is, if anything, slightly larger. Still true on vLLM 0.26.0 (2026-07-28): the warning names FlashInferBackend's AttentionCGSupport.UNIFORM_SINGLE_TOKEN_DECODE, the speculative arm captures 7 PIECEWISE graphs, and the no-draft baseline captures 3 FULL decode graphs on top of its 4 piecewise ones.
- You want more context than 262,144 tokens.
- There isn't any — max_position_embeddings is 262,144 and that is what this recipe serves. The unusual thing about this model is the opposite problem: the KV pool is far larger than one user can use (2.63x concurrency at full length), so the tuning move is to shrink --kv-cache-memory-bytes toward ~3.5 GiB and hand the memory back, not to grow it. Re-read the pool size from the log after any change to num_speculative_tokens, since the draft takes its state out of the same pool: 812,849 tokens at k=0, 689,341 at k=3, 675,451 at k=4.
- A 250,000-token request seems to hang.
- It is prefilling. Decode on this model degrades gently with context — 61.72 tok/s at 2048 tokens, 60.94 at 32,778, 54.48 at 131,290, 47.45 at 250,222 — but time-to-first-token does not: 0.40 s, 6.4 s, 48.2 s and 141.5 s at those same four prompt lengths, as prefill throughput falls from ~5,100 to ~1,770 tok/s. A quarter-million-token prompt therefore costs well over two minutes before the first token appears. That is the price of the context, and it is why every decode figure on this page is quoted with the prompt length it was taken at. (The long-prompt rows were measured on the second, identical Spark running the same config.)
- Ruled out, so you don't have to: things that were NOT needed.
- No vLLM patch (the checkpoint loads on stock 0.24.0 and stock 0.26.0). No --moe-backend or --attention-backend override (auto-selection is correct on sm_121 — though it picks a different NVFP4 MoE backend per engine version: FLASHINFER_CUTLASS on 0.24.0, VLLM_CUTLASS on 0.26.0, with no measured decode difference). No --enforce-eager (that is a cross-node TP2 workaround; this is a single node and cudagraphs are fine). No page-cache purge (--kv-cache-memory-bytes skips the startup free-memory check that the purge exists to satisfy). No --trust-remote-code. And no lowering of --max-num-seqs — that would shrink the speculative CUDA-graph capture sizes and cost decode.
Revision history
What has changed on this page since it was published, and what it measured. Newest first.
- re-measured
Re-verified end to end on vLLM 0.26.0 (Spark-2): both arms re-measured back to back in one session, no flag changes needed. Headline decode 61.72 → 62.02 tok/s and the no-draft baseline 37.78 → 36.83, so the speculative ratio reads 1.68x instead of 1.63x. Two engine-version facts corrected on the page: the NVFP4 MoE backend auto-selection now lands on VLLM_CUTLASS rather than FLASHINFER_CUTLASS, and the --speculative-config method should be spelled "mtp" ("qwen3_5_mtp" still works but logs a deprecation).
At a 2,048-token code prompt, warm, greedy, concurrency 1, same node: decode 61.72 → 62.02 tok/s with the MTP draft at k=3, 37.78 → 36.83 tok/s with no draft, ratio 1.63x → 1.68x. Draft acceptance 0.652 → 0.700 overall. Memory unchanged: 23.81 GiB loaded with the draft, 22.24 GiB without, KV pool 689,341 / 812,849 tokens.
Memory budget
This model uses about a third of a Spark, and that is the interesting fact about its memory. The NVFP4 export is 25.4 GB on disk and vLLM reports 'Model loading took 22.24 GiB' without the draft, 23.81 GiB with it — so the MTP head costs a measured 1.57 GiB, which is a very cheap way to buy 63% more decode. KV then costs almost nothing: the architecture runs full attention on only 10 of its 40 layers, those layers carry 2 KV heads at head_dim 256, and the checkpoint's hf_quant_config declares kv_cache_quant_algo FP8, which vLLM honours (kv_cache_dtype=fp8_e4m3). The result is roughly 12.2 KiB per token at k=3, so the 8 GiB pinned pool holds 689,341 tokens — 2.63x concurrency at the model's full 262,144-token context. For a single user, anything above about 1.2x is headroom nobody spends: the pool could be cut to ~3.5 GiB and still serve full-length requests, and the 8 GiB figure published here is simply the value that was measured. Steady-state free -g with the API up and idle reads 46 GiB used of 121; subtracting the ~4 GiB idle OS baseline leaves ~42 GiB of serving working set against the 114 GiB ceiling, so more than 70 GiB stays free. The weights, draft and KV figures are measured from the engine log; the split of the 22.24 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.