Recipe
Everything needed to serve, tune, and benchmark this model — in order. Each step says what to run, what you should see, and what to do when it goes wrong.
Time: ~20 minutes to a working server, plus ~1 hour for the full benchmark suite.
What you need
| Minimum used here | |
|---|---|
| GPUs | 2× NVIDIA RTX PRO 6000 Blackwell (96 GB, SM120) |
| VRAM | ~193 GB total across both cards |
| Disk | ~400 GB for the checkpoint |
| Software | vLLM with DeepSeek-V4 sparse-MLA support, CUDA 13.x |
Less VRAM? Lower --max-model-len from 262144 — that is the main memory lever,
since the KV dtype cannot be reduced on this architecture (see Step 6).
Step 1 — Serve the model
vllm serve /path/to/model \
--served-model-name dsv4-hybrid-vision dsv4-hybrid \
--host 127.0.0.1 --port 8000 \
--tensor-parallel-size 2 \
--tokenizer-mode deepseek_v4 \
--kv-cache-dtype fp8_ds_mla \
--block-size 256 \
--max-model-len 262144 \
--max-num-seqs 16 \
--max-num-batched-tokens 8192 \
--gpu-memory-utilization 0.968 \
--speculative-config '{"method":"dspark","model":"/path/to/model","num_speculative_tokens":5}' \
--compilation-config '{"cudagraph_mode":"FULL_DECODE_ONLY","max_cudagraph_capture_size":8}' \
--limit-mm-per-prompt '{"vision_chunk":8}' \
--enable-auto-tool-choice \
--tool-call-parser deepseek_v4 \
--generation-config vllm \
--default-chat-template-kwargs '{"thinking": false, "enable_thinking": false}' \
--override-generation-config '{"max_new_tokens": 1024, "temperature": 0.6, "top_p": 0.95}'
Expect: ~3 minutes to load. Success looks like:
GPU KV cache size: 263,310 tokens
Application startup complete
Check it:
curl -s http://127.0.0.1:8000/v1/models | python3 -m json.tool
Why these flags
| Flag | Reason |
|---|---|
--tokenizer-mode deepseek_v4 |
Required. This checkpoint ships no chat_template; turn formatting is built in Python by the deepseek_v4 tokenizer, which never evaluates Jinja. |
--kv-cache-dtype fp8_ds_mla |
The only working option on SM120 (Step 6). |
--max-num-seqs 16 |
4 capped throughput at 227 tok/s with 3.3 s tail latency; 16 gives 486 tok/s at 1.74 s. KV cache usage stayed under 1%, so the cap was a scheduler limit, not memory. |
--override-generation-config |
With --generation-config vllm, the checkpoint's own generation_config.json is ignored and you inherit vLLM's temperature=1.0. This sets sane defaults (Step 3). |
--default-chat-template-kwargs |
Pins chain-of-thought off so a stray client kwarg cannot enable it server-wide. |
Step 2 — Warm up the JIT kernels
vLLM compiles Triton/TileLang kernels the first time it sees each tensor shape, inside whichever request happens to arrive. Its own log says so:
WARNING [jit_monitor] Triton kernel JIT compilation during inference:
_compute_prefill_metadata_kernel. This causes a latency spike;
consider extending warmup to cover this shape/config.
Measured: 12 events in the first 14 minutes, each ~6 s against an 0.086 s median. Issue one request per shape class right after startup instead:
#!/usr/bin/env bash
# warmup.sh -- run once after the server is ready
URL=http://127.0.0.1:8000/v1/chat/completions
warm() { # $1 = filler word count, $2 = max_tokens, $3 = label
local filler; filler=$(python3 -c "print('word '*$1)")
curl -s -m 600 "$URL" -H 'Content-Type: application/json' \
-d "$(python3 -c "
import json
print(json.dumps({'model':'dsv4-hybrid-vision','max_tokens':$2,
'messages':[{'role':'user','content':'Reply ok. '+'''$filler'''}]}))")" -o /dev/null
echo " warmed $3"
}
warm 5 16 tiny
warm 100 16 short
warm 800 16 medium
warm 3000 16 long
warm 10000 16 xlong
warm 20 256 decode-deep
Also send one 448 px image and one tool-calling request — those are separate code paths with their own kernels.
Expect: ~2 s once the cache is populated; longer the first time. Compiled kernels persist in vLLM's JIT cache directory, so mount it on durable storage to keep the benefit across restarts.
Verify it worked: every jit_monitor line in the server log should fall
inside your warmup window, not during later user traffic.
Step 3 — Fix verbose output
Symptom: ask for one bash one-liner, get five variants plus a bullet-point explainer — 347 tokens for a one-line question.
Cause, in two parts:
- No system prompt. The checkpoint ships no chat template, so nothing steers response length.
- Sampling.
--generation-config vllmignores the checkpoint'sgeneration_config.json, leavingtemperature=1.0,top_p=1.0.
Fix part 1 — sampling, already in the Step 1 command:
--override-generation-config '{"max_new_tokens": 1024, "temperature": 0.6, "top_p": 0.95}'
Fix part 2 — a default system prompt. vLLM has no flag for this, and the
usual --chat-template trick does not apply because --tokenizer-mode deepseek_v4
never evaluates Jinja. Send one per request:
SYSTEM = """Be concise and direct. Answer what was asked, then stop.
- Give ONE best answer, not a menu of alternatives. Compare options only if asked.
- No preamble, no restating the question, no closing summary or recap.
- Do not explain self-evident code. Note only genuinely surprising behaviour.
- Prefer a sentence over a paragraph, a paragraph over a list.
- Match length to the question: a one-line question gets a one-line answer."""
messages = [{"role": "system", "content": SYSTEM}] + user_messages
For a fleet, put a small proxy in front that injects this when the caller supplies no system message of their own.
Result:
| Prompt | Before | After |
|---|---|---|
| "bash one-liner, 10 largest files under /var/log" | 347 tokens, 5 variants | 29 tokens, 1 command |
| "What port does SSH use?" | 54 tokens | 3 tokens (22.) |
Step 4 — Cap image resolution at 448 px
Do this before sending any image. Above ~450 px the model drifts toward answering "yes" to everything and invents text that is not in the picture.
from PIL import Image
import base64, io
def encode_image(path, max_side=448):
im = Image.open(path).convert("RGB")
im.thumbnail((max_side, max_side)) # preserves aspect ratio
buf = io.BytesIO(); im.save(buf, "PNG")
return base64.b64encode(buf.getvalue()).decode()
Measured effect (balanced yes/no questions, equal true-yes and true-no):
| Image | Full resolution | Capped at 448 px |
|---|---|---|
| Desert landscape | 5/7 | 7/7 |
| Mountain at sunset | 5/7 | 6/7 |
It also cuts prompt tokens 12× — a 4736×2656 photo drops from ~3,100 to ~260.
Step 5 — Benchmark it
Install the harness:
pip install lm-eval==0.4.12 transformers langdetect immutabledict
Multiple choice (loglikelihood — needs /v1/completions)
lm_eval --model local-completions \
--model_args "model=dsv4-hybrid,base_url=http://127.0.0.1:8000/v1/completions,\
num_concurrent=16,max_retries=3,tokenized_requests=False,\
tokenizer=/path/to/model,tokenizer_backend=huggingface" \
--tasks arc_challenge,hellaswag,winogrande,piqa,openbookqa,truthfulqa_mc2 \
--limit 300 --batch_size 16 --output_path eval_mc
Generative (chat endpoint)
lm_eval --model local-chat-completions \
--model_args "model=dsv4-hybrid,base_url=http://127.0.0.1:8000/v1/chat/completions,\
num_concurrent=16,max_retries=3,tokenized_requests=False,\
tokenizer=/path/to/model,tokenizer_backend=huggingface" \
--tasks gsm8k,ifeval --limit 200 --apply_chat_template --output_path eval_gen
Code — use the custom script
python3 benchmarks/humaneval_chat.py
Do not trust lm_eval --tasks humaneval on a chat endpoint. It reports
0.0 because the task expects a raw completion while the model returns prose
plus a ```python fence. The script above extracts the fenced code and
runs the official tests: 78.0% pass@1.
Gotchas
| Error | Fix |
|---|---|
got multiple values for keyword argument 'batch_size' |
batch_size is a CLI flag, not a --model_args key. |
No module named 'transformers' |
Install into the same environment as lm_eval. |
dsv4-hybrid is not a local folder... |
Pass tokenizer=/path/to/model — it defaults to resolving the served name on the Hub. |
No module named 'langdetect' |
pip install langdetect immutabledict (IFEval). |
| HumanEval scores 0.0 | Expected on a chat endpoint. Use humaneval_chat.py. |
Step 6 — Do not bother with nvfp4 KV cache
nvfp4_ds_mla would cut the KV record from 656 to 432 B/token (~1.52× more
cache). Both the vLLM CLI and the b12x backend advertise it. It does not work on
SM120 — the engine fails at load:
AssertionError: DeepseekV4 fp8_ds_mla layout only supports fp8 kv-cache,
got nvfp4_ds_mla
Three independent blockers, each verified:
| Layer | Blocker |
|---|---|
| vLLM Python | Every DSv4 attention class reachable on SM120 sets use_fp8_ds_mla_layout = True, which asserts dtype.startswith("fp8") |
| FlashInfer API | Only trtllm_batch_decode_sparse_mla_dsv4 exists; no nvfp4 variant |
| CUDA kernel | Prebuilt cubin, documented as 584 B/token, BF16 or FP8 E4M3 only |
Patching only the Python assertion would feed 432-byte records to a kernel reading 584-byte records: silent numerical corruption, not a clean error. Do not do it.
Of the 17 dtypes the CLI advertises, six are accepted (fp8, fp8_ds_mla,
fp8_e4m3, fp8_e5m2, fp8_inc, fp8_per_token_head) and all six resolve to
the same 656 B record — none changes capacity. This needs upstream support.
Step 7 — Verify before you ship
Quick checks that catch the common regressions:
# 1. Terse output (expect ~3 tokens: "22.")
curl -s http://127.0.0.1:8000/v1/chat/completions -H 'Content-Type: application/json' \
-d '{"model":"dsv4-hybrid","messages":[{"role":"user","content":"What port does SSH use?"}]}' \
| python3 -c "import json,sys;d=json.load(sys.stdin);print(d['usage']['completion_tokens'],'tok:',d['choices'][0]['message']['content'])"
# 2. Tool calling (expect finish_reason: tool_calls)
curl -s http://127.0.0.1:8000/v1/chat/completions -H 'Content-Type: application/json' -d '{
"model":"dsv4-hybrid","messages":[{"role":"user","content":"Weather in Paris?"}],
"tools":[{"type":"function","function":{"name":"get_weather","description":"Get weather",
"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}]}' \
| python3 -c "import json,sys;d=json.load(sys.stdin);print(d['choices'][0]['finish_reason'])"
# 3. Streaming (expect several chunks then [DONE])
curl -sN http://127.0.0.1:8000/v1/chat/completions -H 'Content-Type: application/json' \
-d '{"model":"dsv4-hybrid","stream":true,"messages":[{"role":"user","content":"Count 1 to 5."}]}' \
| grep -c '^data:'
Benchmark tip: when comparing runs, give every prompt a unique prefix. vLLM's prefix cache is on by default (~86% hit rate in normal use); re-sending an identical prompt returns in 0.11 s instead of 2.68 s. Measuring that reports impossible numbers — 276,000 tok/s prefill in one early run here. A nonce in the prompt prefix invalidates the cached sequence and restores honest timings.