deepseek-ai/DeepSeek-V4-Flash-Vision-Exp weights (draft heads ship as mtp.*)
~120 GB free per node for weights and the JIT kernel cache
Root on every node (RDMA passthrough, memlock)
earlyoom or equivalent on every node, tuned to fire before the kernel OOM killer
DeepSeek-V4-Flash-Vision-Exp served on four DGX Spark GB10 nodes: TP4 with decode-context-parallel 2, DSpark speculative decoding, thinking on, images in. The stack runs on the same sparkinfer B12X kernels as the text deployment; preprocessing, tower, routing and attention match upstream vLLM's vision support (PR #54566, merged 2026-09-02). Upstream has neither DCP nor speculative decoding for the vision variant. Image: aidendle94/sparkrun-vllm-ds4-gb10:production-3.73-vision.
02Build
Memory budget
GB10 memory is shared between host and GPU, and earlyoom on these nodes fires at 2% MemAvailable. The first "DSpark hang" investigated here was an earlyoom SIGTERM: at --gpu-memory-utilization 0.82 the boot left ~2.7 GiB free, one worker was killed mid-request, and the other three ranks waited on a collective that never came. 0.75 leaves 8–15 GiB per node through 334K prefill and 8-way image stress. Two rules follow.
Clear the pinned KV size the launcher inherits from its version file (KV_CACHE_MEMORY_BYTES= set to empty). Left in place it caps the pool at ~26 GB per rank and hides the DCP capacity gain entirely.
On a two-node TP2 layout each node carries half the weights plus the draft: 0.75 fails KV allocation outright, 0.82 leaves one rank below the minimum for 163K, 0.85 works. Budgets do not transfer between layouts; read the Available KV cache memory line per rank.
Apply the host memory floor from the GLM recipe on every node (vm.min_free_kbytes=1048576, vm.dirty_bytes=536870912, vm.dirty_background_bytes=134217728, vm.dirty_expire_centisecs=500, vm.dirty_writeback_centisecs=100, vm.swappiness=10, persisted in /etc/sysctl.d/). The driver needs genuinely free pages and never evicts cache; dirty pages from any large write are what drop_caches cannot free, and a download on the head node killed a running engine that way on 2026-09-09.
Per-node deployment
One container per node, host networking, RDMA passthrough, the model and a writable JIT cache bind-mounted, driven by environment variables. Every node needs its own .env: the fabric address differs per node, and copying the head's file over a worker's makes that worker try to bind the head's address.
.env · per node (production values)
IMAGE=aidendle94/sparkrun-vllm-ds4-gb10:production-3.73-vision
MODEL_HOST_PATH=<path-to>/models--deepseek-ai--DeepSeek-V4-Flash-Vision-Exp
CACHE_HOST_PATH=<writable-jit-cache-dir>
SERVED_MODEL_NAME=deepseek-v4-vision
API_PORT=8300
NNODES=4 TP_SIZE=4
MASTER_ADDR=<head-fabric-ip> MASTER_PORT=29511
VLLM_HOST_IP=<this-node-fabric-ip> # differs per node
FABRIC_IFACE=<fabric-iface> IB_HCA=<rdma-device> IB_GID_INDEX=<resolved-at-boot>
# layout: TP4 across the nodes, DCP2 inside it
DCP_SIZE=2
DCP_COMM_BACKEND=ag_rs # +12% KV over a2a, decode within noise
KV_CACHE_MEMORY_BYTES= # empty on purpose: unpins the ~26 GB cap
GPU_MEMORY_UTILIZATION=0.75 # 0.82 gets a worker killed by earlyoom
MAX_MODEL_LEN=393216 MAX_NUM_SEQS=8 MAX_NUM_BATCHED_TOKENS=8192
# speculative decoding
VLLM_ENABLE_DSPARK=1 VLLM_ENABLE_MTP=0
NUM_SPECULATIVE_TOKENS=6
DSPARK_DRAFT_SAMPLE_METHOD=greedy DSPARK_SPS_CURVE=auto
MAX_CUDAGRAPH_CAPTURE_SIZE=56 # = MAX_NUM_SEQS × (spec tokens + 1)
VLLM_CUDAGRAPH_MODE=FULL_AND_PIECEWISE
# prefill gather (required for images under DCP)
VLLM_DSV4_CKV_GATHER=1
VLLM_DSV4_CKV_GATHER_MAX_TOKENS=393216
VLLM_DSV4_CKV_ALLOW_IMAGES=1
ENABLE_PREFIX_CACHING=1
VLLM_DSV4_DCP_PREFIX_CACHE=1 # unset = 3.72 behaviour
VISION_MHC=1
Drop page caches on every node, start the three workers, then the head. Healthy in ~10 minutes: weight load, draft load, KV allocation, 74 s of graph capture, then the DSpark SPS-curve profile.
Read two lines from rank 0 before trusting anything: GPU KV cache size (expect ~3.5M tokens) and the acceptance length from a decode bench (expect 3.5–3.8).
Send one image first. The first image request compiles the partition kernel (~6 s); later ones take about a second for a 350-token image prompt.
Send one long prompt twice. vllm:prefix_cache_hits_total in /metrics should climb by the prompt length rounded down to 512 tokens, and the second answer should start within about a second. Zero hits means the container never received VLLM_DSV4_DCP_PREFIX_CACHE=1.
image request · OpenAI-compatible
{"model": "deepseek-v4-vision",
"messages": [{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}},
{"type": "text", "text": "What animal is this? Two sentences."}]}],
"max_tokens": 160, "temperature": 0,
"chat_template_kwargs": {"thinking": false}}
# → "This is a brown and black tabby cat with striking green eyes. It is sitting
# upright on a textured white ledge, with blurred bare branches visible in the
# background." (352 prompt tokens, 1.2 s warm)
Figures are from the live four-node cluster with unique-content prompts and completion_tokens accounting; acceptance lengths and cache hits come from the /metrics counters. Prefix-cache figures are from production-3.73-vision on 2026-09-04; profile comparison rows are from 3.72-vision on the same nodes.
03Deep dive
Vision port details
Preprocessing and layout. Resize solver, patch grid, the N-layout row-pair permutation and the five sentinel tokens (start, pad, image, newline, end) are bit-identical to the checkpoint's reference code, verified over odd row counts, extreme aspect ratios and sub-minimum images. Sentinels live past the text vocabulary and are sanitized before every embedding lookup.
Routing for image tokens. Image rows route on scores + bias_vl with the unbiased scores as weights; text rows keep the stock kernel path. Hash-routed layers need the score path for image rows, and their tid2eid table is exactly vocab_size rows tall, so an unguarded lookup on a sentinel id is out-of-bounds memory or a device assert.
Sliding-window visibility. Image tokens see the whole image (not the causal window) plus the trailing text window. The B12X dual-cache prefill is pinned to a 128-wide window, so extra slots run as separate 128-wide attention partitions merged exactly in fp32 from base-2 log-sum-exps; measured max error against a single-partition reference is 2.4e-7.
Multi-image batches. The tower runs per-item grids; concatenating patches across requests tripped a rotary size mismatch under concurrency.
DSpark and DCP together
The stock stack refuses this combination twice: the launcher exits with "DSpark requires DCP_SIZE=1" and the sliding-window metadata builder asserts "Non-causal DSpark SWA does not support DCP". The verify pass is causal and already has full DCP kernels. The non-causal path belongs to the draft: three full decoder layers, sliding-window only, whose block forward attends over the whole speculative block at once. Its KV cache was landing in the target's sharded sliding-window group, where the draft's slot math is wrong.
Write DCP-aware draft kernels — not done
Needs a new non-causal index kernel with rank ownership tests, DCP-aware slot mapping and a virtual-block shift for the draft block table.
Cross-rank differences in draft confidence would change the capacity decision per rank, which the TP-consistency check treats as a hang.
Replicate the draft KV on every DCP rank — shipped
Mark the draft sliding-window cache dcp_replicated: its own KV group with the full context on every rank, as the DFlash draft already runs under DCP in this fork. The metadata builder and attention gate treat that group as DCP=1.
A sliding-window cache is a few hundred tokens per request, so replication costs nothing measurable and draft confidence is identical across ranks.
The DSpark SPS-curve profiler was the first DCP consumer of the generic dummy-run branch, which built batches without rank-local sequence lengths; the CUDA-graph capture path computes them itself, which is why capture passed and the profiler asserted. The dummy branch now derives them the way real batches do.
--dcp-comm-backend ag_rs over a2a: same decode within noise, +12% KV (3.51M vs 3.13M tokens) because all-to-all reserves more scratch.
Prefix caching under DCP was off in 3.72-vision by design; 3.73-vision turns it on. What was actually wrong is below.
Prefix caching under DCP (3.73-vision)
In 3.72-vision the KV coordinator carried a blanket guard for this model's MLA/SWA hybrid layout, so --enable-prefix-caching was accepted and inert and every request paid a cold prefill. The guard hid a bug: under DCP each rank holds one local block per block_size × dcp global tokens, and the sliding-window manager registered hashes at that scaled size but looked them up at the unscaled 64-token block, so nothing it inserted could be found. The coordinator also handed every sliding-window group a DCP size of 1. Two changes fix it: lookup and the sparse-retention mask use the manager's DCP-scaled block size (replicated groups scale by 1), and the coordinator passes the real DCP size to every attention group and asserts a hit ends on a scheduler-block boundary, 512 tokens at DCP2.
Hits come in 512-token blocks. The tail past the last boundary is recomputed every time, and every sliding-window and compressor-state group has to hold its window tail at that boundary. A 1.3K prompt gets 77% of itself from cache, a 92K prompt 100%.
DSpark keeps speculating on cached prefixes. The draft only requires the cached length to be a multiple of every kernel block size, which a 512-aligned hit always is. Acceptance on warm requests measured 3.3–3.9 tokens per step, the same as cold.
Opt-in.VLLM_DSV4_DCP_PREFIX_CACHE=1 turns it on; unset, 3.73-vision behaves like 3.72. The scheduler logic was exercised offline on CPU with a synthetic copy of this model's KV groups at DCP 1, 2 and 4.
Prompt
Cold prefill
Warm prefill
Warm hits
Same greedy output
Needle facts (cold / warm / new question)
1.3K tokens
197 tok/s (JIT cold)
964 tok/s
77%
yes
5/5 · 5/5 · 5/5
2.6K
1,245
1,956
97%
yes
5/5 · 5/5 · 5/5
9.2K
1,979
6,530
95%
no — diverged at character 37, both correct
5/5 · 5/5 · 5/5
33K
2,350
23,377
98%
yes
5/5 · 5/5 · 5/5
92K
2,380
60,249
100%
yes
5/5 · 5/5 · 5/5
Each prompt sent cold, then identically, then with the same document and a different question, then by three concurrent requests sharing the document (81–100% hits, every fact correct). Three concurrent 56K-token streams re-sent after a cold pass went from 45–74 s to 0.5–1.0 s TTFT. Decode (67.9 tok/s, acceptance 3.69) and vision end-to-end were unchanged.
The prefill gather
Stock DCP prefill on this model all-gathers query heads and reduces log-sum-exps across ranks per layer, about a gigabyte per 8K-token chunk at DCP4; cold 334K prefill collapsed to 1,046 tok/s at DCP4 and 1,440 at DCP2. This port gathers each rank's slice of the compressed and sliding-window caches into a rank-local arena in global token order once per layer, then runs the unchanged DCP=1 attention against a synthetic contiguous block table. Cold 334K prefill: 2,184 tok/s at DCP4, 2,119 at DCP2, byte-identical outputs with the gather on or off.
Images under DCP need the gather: image tokens must read slots that stock sharded prefill drops, so the stack refuses image requests under DCP unless the gather is enabled and images are explicitly allowed (VLLM_DSV4_CKV_GATHER=1, VLLM_DSV4_CKV_ALLOW_IMAGES=1).
04Performance
Measured 2026-09-04. Four-node TP4 · DCP2 (ag_rs) · DSpark k=6 · images on · prefix caching on. Prefill and TTFT figures are the server's own usage and /metrics counters; decode is three 400-token code completions at temperature 0. Image: aidendle94/sparkrun-vllm-ds4-gb10:production-3.73-vision.
Decode67.9tok/ssingle stream, code prompts, DSpark acceptance 3.69 (66–71 across runs)
Cold prefill2,380tok/sunique 92K-token prompt in 38.8 s; 2,088 at 334K depth
Warm prefill60,249tok/sthe same 92K prompt re-sent: 1.5 s end to end, 100% prefix-cache hit
TTFT @ 56K45–74 → 0.5–1sthree concurrent streams, first pass cold, second from cache
KV pool3.45Mtokens8.78 concurrent full-length contexts at budget 0.75
Context393Ktokens3×56K concurrent coherence PASS; 5×128K on 3.72-vision
Image request~1.2s200–350-token image prompt once the partition kernel is warm
Draft acceptance3.3–3.9tokens/stepDSpark, 6 speculative tokens; unchanged on cached prefixes
Prefill throughput by prompt size — cold vs warmtok/s · higher is better
cold (first send)warm (prefix resident)
2.6K tokens1,2451,956+57%
9.2K tokens1,9796,5303.3×
33K tokens2,35023,3779.9×
92K tokens2,38060,24925×
The same prompt sent twice. Warm throughput is prompt tokens over end-to-end latency, so it still pays for the tail past the last 512-token boundary and for the first output token.
Time to first token, three concurrent 56K-token streamsseconds · lower is better
coldwarm
stream 045.4 s1.0 s45×
stream 122.7 s0.5 s45×
stream 274.4 s1.0 s74×
Cold TTFT includes queueing behind the other two streams' prefill. The warm pass re-sent the same three prompts; every stream still answered 5/5 needle facts with no loops and no cross-talk.
Prefix-cache hit rate, identical prompt re-sent% of prompt tokens served from cache
1.3K tokens77%
2.6K tokens97%
9.2K tokens95%
33K tokens98%
92K tokens100%
Under DCP2 a hit must end on a 512-token boundary (256 × dcp), so the tail past the last boundary is recomputed every time; short prompts show it, long ones do not. Three concurrent requests sharing one prefix hit 81–100%.
Decode by profile, same nodestok/s · single stream · higher is better
DSpark, DCP174.5
DCP2, no speculation34.0
DSpark + DCP2 (shipped)shipped67.9
The two comparison profiles were measured on 3.72-vision; the shipped row is 3.73-vision with prefix caching on. The ~7% gap to DCP1 is the per-layer cross-rank reduce during decode.
KV pool by profiletokens · higher is better
DSpark, DCP11,769,706
DCP2, no speculation4,041,732
DSpark + DCP2 (shipped)shipped3,452,405
At --gpu-memory-utilization 0.75 and 393,216 max context. 3.72-vision measured 3,508,513 on the shipped profile; the 1.6% difference from 3.73-vision has not been separated from the memory profiler's boot-to-boot variance.
disabled by the KV coordinator for this model's MLA/SWA hybrid layout — 0% hits
on, opt-in with VLLM_DSV4_DCP_PREFIX_CACHE=1; hits land on 512-token boundaries
Root cause fixed
the sharded sliding-window group registered hashes at 64 × dcp-token blocks but looked them up at 64; the coordinator handed sliding-window groups a DCP size of 1
lookup and retention mask use the manager's DCP-scaled block size; the coordinator passes the real DCP size to every attention group and asserts hit alignment
DSpark draft under DCP
replicated draft cache
unchanged; speculation stays on for cached prefixes
cold/warm needle facts 5/5 at 1.3K–92K, concurrent shared prefix, 3×56K coherence, vision e2e, decode bench; long stress suite not re-run with caching on
Known open items
—
one 9K prompt's warm greedy output diverged at character 37 with both answering every fact; one 33K prompt hit one 512-token block short
Same weights and nodes on both sides unless the row says otherwise.
Across recipes
Single-stream decode, by recipetok/s · higher is better
DeepSeek-V4.1-Flash · SGLang (1.2)94.1
DeepSeek-V4.1-Flash (1.1, DCP2)60.8
GLM-5.3-Flash (1.2)89.7
DeepSeek-V4-Flash (3.76.1)71.0
DeepSeek-V4-Flash-Vision (3.73)shipped67.9
Different prompt sets, all single stream at temperature 0 with completion_tokens accounting: exact within a recipe, indicative across them. DeepSeek-V4.1-Flash · SGLang (1.2): code prompts, DSpark block 5, RoCE collectives, early Engram staging; 110 counting, 37 prose · DeepSeek-V4.1-Flash (1.1, DCP2): code prompts, DSpark k=5, b12x MoE, DCP2 (77.5 on 1.0 with a different prompt set) · GLM-5.3-Flash (1.2): structured output, thinking off · DeepSeek-V4-Flash (3.76.1): mixed prompts, 8-rep mean · DeepSeek-V4-Flash-Vision (3.73): code prompts, DCP2 + DSpark
Cold prefill, by recipetok/s · unique content · higher is better
DeepSeek-V4.1-Flash · SGLang (1.5)4,113
DeepSeek-V4.1-Flash (1.0)2,143
GLM-5.3-Flash (1.2)2,070
DeepSeek-V4-Flash (3.76.1)2,200
DeepSeek-V4-Flash-Vision (3.73)shipped2,380
Cold means the prompt has never been seen by the server; with prefix caching on, a repeated prompt is a cache hit and reads 3–25× higher. DeepSeek-V4.1-Flash · SGLang (1.5): 32K depth, unique random words (2,913 at 128K) · DeepSeek-V4.1-Flash (1.0): 36K depth (1,940 at 132K) · GLM-5.3-Flash (1.2): 32K depth · DeepSeek-V4-Flash (3.76.1): 334K depth · DeepSeek-V4-Flash-Vision (3.73): 92K depth (2,088 at 334K)
Three profiles measured on the same nodes and image; the combined one is what ships.
Profile
Decode
334K cold prefill
KV pool
DSpark, DCP1 (the text-style profile)
73.8–75.2 tok/s
2,170 tok/s
1,769,706 tokens (4.5×)
DCP2, no speculation
34 tok/s
2,119 tok/s
4,041,732 tokens (10.3×)
DSpark + DCP2 (shipped)
66–71 tok/s
2,088 tok/s
3,508,513 tokens (8.9×)
Same harness throughout: three 400-token code completions, temperature 0, acceptance from the /metrics counters. The ~7% decode gap to DCP1 is the per-layer cross-rank reduce during decode. Rows measured on 3.72-vision; 3.73-vision measured 67.9 tok/s at acceptance 3.69 and a 3,452,405-token pool.
05Notes
One node ran hot
One of the four units reset silently three times, always one to three minutes into an all-node 334K prefill — no shutdown record, no kernel error, no thermal message; the journal just stops. Sampling every node every two seconds under identical load explained it: that unit's GPU ran at 87–89 °C and 63–70 W where its twin ran 77–80 °C and 51–59 W, on identical firmware. The critical trip is 104.8 °C, and a firmware trip looks exactly like a power cut. Capping that GPU at 90% of its application clock (2,176 MHz) brought it to 69 °C and 43 W under the same load for a 1.2% cluster prefill cost.
Operational caveats
The DCP image guard is engine-fatal.An image request with the gather disabled raises inside the model forward, and any exception there kills the engine core. Forward VLLM_DSV4_CKV_ALLOW_IMAGES through compose on every node; moving the check to the request processor is the pending fix.
Per-node .env files are not interchangeable.Only VLLM_HOST_IP (and cache paths) differ, and a stray rsync of the head's file produced a ZMQ bind to the wrong address on a worker.
Watch MemAvailable, not GPU memory.On unified memory the number that matters is what earlyoom sees. A 5-second sampler per node during validation is cheap insurance.
Benchmark prefill with unique content.With prefix caching on, a repeated prompt is a cache hit: 60K tok/s at 92K is the second send, not the first.
Hits come in 512-token blocks at DCP2.A hit must end on a scheduler-block boundary (256 × dcp tokens); the tail past it is recomputed, so a 1.3K prompt shows 77% hits where a 92K prompt shows 100%.
Kill by PID file, never by pattern.A pkill -f whose pattern also appears in the invoking shell's command line kills the shell.
Sustained long prefill is the thermal stress test.If a node drops with an empty journal, sample its sensors from another machine before blaming software.