File size: 11,421 Bytes
2941b49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
# 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

```bash
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:**

```bash
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:

```bash
#!/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:**

1. **No system prompt.** The checkpoint ships no chat template, so nothing steers
   response length.
2. **Sampling.** `--generation-config vllm` ignores the checkpoint's
   `generation_config.json`, leaving `temperature=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:

```python
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.

```python
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:

```bash
pip install lm-eval==0.4.12 transformers langdetect immutabledict
```

### Multiple choice (loglikelihood — needs `/v1/completions`)

```bash
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)

```bash
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

```bash
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:

```bash
# 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.