Serve the 284B/13B DeepSeek V4 Flash DSpark checkpoint across two DGX Sparks at its full 1M-token context, with FP4 experts, an NVFP4 MLA KV cache, and DSpark speculative decoding at k=5 — 42.0 tok/s single-stream on prose and 76.0 on code.
* segment sizes marked with an asterisk are estimates pending a measured run
Eval scores
(compare all)Bench card
| 2K | 8K | 32K | 128K | |
|---|---|---|---|---|
| decode tok/s | 47.4×2 | 48.0×2 | 52.2 | 53.2 |
| ttft | 1.02s×2 | 3.82s×2 | 17.92s | 54.16s |
| prefill tok/s | 1.8k×2 | 1.9k×2 | 1.4k | 1.8k |
| power | 38W | 39W | 51W | 64W |
includes 2 community runs · @joemuller, @lloyd094
median per context · o256
Contributors
Overview
DeepSeek V4 Flash DSpark is a 284B-parameter mixture-of-experts model with 13B active per token, shipped as FP4 routed-expert planes with FP8 attention and dense layers. At 155 GiB on disk it needs two DGX Sparks: tensor parallelism splits it to 79.17 GiB of weights per node, leaving enough of the 114 GiB usable pool for a KV cache large enough to serve the model's full 1,048,576-token context. Two things make the configuration worth the trouble. First, DSpark speculative decoding — the drafter ships inside the checkpoint, costs about a gibibyte per rank, and is worth 2.69 tokens per step on prose and 5.12 on code. Second, decode speed barely moves as context grows: measured 40.5 tok/s at a 512-token prompt and 43.1 at 8K on the same prose workload. What long context actually costs here is time-to-first-token, because prefill runs at 1,000-2,000 tok/s and a very long prompt therefore takes minutes before the first token appears. Tuning this deployment is mostly a prefill problem, not a decode one — and the obvious prefill levers turn out to cost more decode than they return, which is documented below. One correctness note worth reading before you copy any flag: the drafter emits 5-token blocks (dspark_block_size 5), so num_speculative_tokens must be at least 5.
- 284B total / 13B active MoE — FP4 experts, FP8 attention and dense layers, 155 GiB on disk
- 2 × DGX Spark, tensor-parallel 2 over a direct RoCE link (head 192.168.100.1, worker .2)
- Full 1,048,576-token context — the model's architectural max, not a memory-limited number
- NVFP4 MLA KV cache (nvfp4_ds_mla): 8.54 GiB/rank buys a 1,268,110-token pool (1.21x at 1M)
- DSpark speculative decoding, k=5 — the checkpoint's dspark_block_size, and the minimum that is correct
- Decode 42.0 tok/s on prose and 76.0 on code, single-stream at a 2048-token prompt (warm)
- Draft acceptance 0.34 prose / 0.83 code, worth 2.69 and 5.12 tokens per step
- Decode holds flat as context grows; long context costs TTFT, not tok/s
- Runs on a pinned prebuilt image — as of vLLM 0.26.0 no released vLLM serves this checkpoint on GB10
Software requirements
- Docker with the compose plugin (v2.24+), on both nodes
- Prebuilt image ghcr.io/anemll/dspark-vllm-gx10:0.1.1 — vLLM 0.25.2.dev0+g752a3a504
- Passwordless SSH from the head to the worker over the RoCE address (192.168.100.2)
- NVIDIA driver 580.159.03 (CUDA 13 capable) — default on DGX Spark
- RoCE tooling: show_gids, ibstat, ibdev2netdev — default on DGX Spark
- ~156 GB free disk per node for the HuggingFace cache
- The four deployment files this recipe inlines below — docker-compose.yml, .env, start.sh and stop.sh. Nothing to clone; the recipe is self-contained. docker-compose.yml goes on BOTH nodes at the same path; the .env and scripts live on the head
Quick start
- 1
Free both nodes first
This deployment needs ~115 GiB of the 121 GiB on each box, so nothing else can be resident. A leftover Ray cluster or vLLM server from earlier work will silently cost you the KV pool, and the failure arrives ten minutes later as an allocation error deep into startup. Check both nodes before you start.
for h in 192.168.100.1 192.168.100.2; do echo "== $h" ssh $h 'free -g | head -2; pgrep -af "vllm|raylet" | head' done # if anything is holding memory: ssh <node> '~/venvs/vllm/bin/ray stop --force; pkill -f "vllm serve"'bashYou want ~117 GiB available and ~5 W idle draw on both nodes before continuing.
- 2
Create the deployment directory and compose file (BOTH nodes)
There is nothing to clone — the recipe is self-contained. Create one working directory on BOTH nodes at the SAME path (start.sh runs docker compose on the worker over SSH from this path), and write docker-compose.yml into it. The compose file pins the runtime image and carries the full vllm serve command; the heredoc is quoted, so paste it verbatim — the $${...} and ${...} are expanded by docker compose at launch, not by your shell.
# on BOTH nodes — identical path on each mkdir -p ~/dspark-v4-flash && cd ~/dspark-v4-flash cat > docker-compose.yml <<'COMPOSE' # DeepSeek V4 Flash DSpark — dual DGX Spark (TP=2, 1M context). # Launched by ./start.sh, which injects NODE_RANK, HEADLESS, VLLM_HOST_IP and # the per-node NCCL_IB_GID_INDEX. See README.md for why the image is pinned. services: vllm-dspark: image: ${DSPARK_VLLM_IMAGE:-ghcr.io/anemll/dspark-vllm-gx10:0.1.1} container_name: ds4-flash-dspark pull_policy: if_not_present entrypoint: [] network_mode: host ipc: host shm_size: "64gb" restart: "no" ulimits: memlock: -1 # RDMA registration; without this NCCL silently falls back to TCP stack: 67108864 gpus: all devices: - /dev/infiniband:/dev/infiniband volumes: - ${HF_CACHE:-${HOME}/.cache/huggingface}:/cache/huggingface - ${DSPARK_TMP_HOST:-${HOME}/.cache/dspark-tmp}:/tmp environment: # ---- model + cache ------------------------------------------------ HF_HOME: /cache/huggingface VLLM_CACHE_ROOT: /cache/huggingface/vllm-cache # Default OFF so a first run can download. Flip to 1 once both nodes # hold the full 48-shard tree, so a later run can never silently refetch. HF_HUB_OFFLINE: "${HF_HUB_OFFLINE:-0}" TRANSFORMERS_OFFLINE: "${TRANSFORMERS_OFFLINE:-0}" HF_HUB_DISABLE_XET: "1" # ---- distributed -------------------------------------------------- NODE_RANK: "${NODE_RANK}" HEADLESS: "${HEADLESS:-}" MASTER_ADDR: "${MASTER_ADDR}" MASTER_PORT: "${MASTER_PORT:-25000}" VLLM_HOST_IP: "${VLLM_HOST_IP:-}" # ---- RoCE fabric --------------------------------------------------- # NCCL_IB_GID_INDEX is injected per node by start.sh, which resolves it # from sysfs at launch. It differs between nodes and drifts across # reboots, so it is deliberately NOT defaulted here. NCCL_NET: "IB" NCCL_IB_DISABLE: "0" NCCL_IB_HCA: "${NCCL_IB_HCA:-rocep1s0f1}" NCCL_SOCKET_IFNAME: "${NCCL_SOCKET_IFNAME:-enp1s0f1np1}" TP_SOCKET_IFNAME: "${TP_SOCKET_IFNAME:-${NCCL_SOCKET_IFNAME:-enp1s0f1np1}}" GLOO_SOCKET_IFNAME: "${GLOO_SOCKET_IFNAME:-${NCCL_SOCKET_IFNAME:-enp1s0f1np1}}" NCCL_IB_GID_INDEX: "${NCCL_IB_GID_INDEX:?start.sh must resolve this per node}" NCCL_IB_ADDR_FAMILY: "AF_INET" NCCL_IB_ROCE_VERSION_NUM: "2" NCCL_CROSS_NIC: "1" NCCL_CUMEM_ENABLE: "0" NCCL_IGNORE_CPU_AFFINITY: "1" NCCL_NVLS_ENABLE: "0" NCCL_DEBUG: "${NCCL_DEBUG:-WARN}" # ---- GB10 / sm_121 toolchain --------------------------------------- TORCH_CUDA_ARCH_LIST: "12.1a" FLASHINFER_CUDA_ARCH_LIST: "12.1a" FLASHINFER_DISABLE_VERSION_CHECK: "1" FLASHINFER_WORKSPACE_BASE: "/cache/huggingface/flashinfer" DG_JIT_USE_NVRTC: "0" DG_JIT_NVCC_COMPILER: "/usr/local/cuda/bin/nvcc" TILELANG_CLEANUP_TEMP_FILES: "1" PYTORCH_CUDA_ALLOC_CONF: "expandable_segments:True" # ---- unified-memory behaviour -------------------------------------- VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1" VLLM_SKIP_INIT_MEMORY_CHECK: "1" VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS: "0" VLLM_TRITON_MLA_SPARSE: "1" VLLM_SPARSE_INDEXER_MAX_LOGITS_MB: "256" # ---- DSpark speculative decoding ----------------------------------- # Clamps stale draft-KV slot ids; required for stable long-context runs. DSPARK_SLOT_CLAMP: "1" VLLM_USE_FLASHINFER_SAMPLER: "1" VLLM_DSPARK_LOCAL_ARGMAX: "1" VLLM_DSPARK_REPLICATE_MARKOV_W1: "1" VLLM_DSPARK_GPU_REJECTED_CONTEXT_MASK: "1" VLLM_DSPARK_HARDWARE_SCHEDULER_EARLY_STOP: "1" VLLM_DSPARK_CONFIDENCE_THRESHOLD: "0.0" VLLM_DSPARK_CONFIDENCE_SCHEDULER: "off" # ---- b12x FP4 MoE kernels (valid for THIS image only) -------------- VLLM_USE_B12X_MOE: "1" VLLM_USE_B12X_WO_PROJECTION: "1" VLLM_B12X_W4A16_FORCE_BLOCKS_MAX_M: "16" MTP_NUM_TOKENS: "${MTP_NUM_TOKENS:-3}" command: - bash - -lc - > export PATH="/usr/local/cuda/bin:/usr/local/bin:$${PATH:-}"; export CUDA_HOME="$${CUDA_HOME:-/usr/local/cuda}"; export LD_LIBRARY_PATH="/usr/local/cuda/lib64:$${LD_LIBRARY_PATH:-}"; exec /usr/local/bin/vllm serve ${DSPARK_MODEL:-deepseek-ai/DeepSeek-V4-Flash-DSpark} --served-model-name ${SERVED_MODEL_NAME:-deepseek-v4-flash-dspark} --host ${VLLM_HOST:-0.0.0.0} --port ${VLLM_PORT:-8888} --trust-remote-code --tensor-parallel-size 2 --pipeline-parallel-size 1 --kv-cache-dtype nvfp4_ds_mla --block-size 256 --max-model-len ${MAX_MODEL_LEN:-1048576} --max-num-seqs ${MAX_NUM_SEQS:-6} --max-num-batched-tokens ${MAX_NUM_BATCHED_TOKENS:-8192} --max-cudagraph-capture-size $$(( ${MAX_NUM_SEQS:-6} * (${MTP_NUM_TOKENS:-3} + 1) )) --gpu-memory-utilization ${GPU_MEMORY_UTILIZATION:-0.85} --enable-prefix-caching --enable-chunked-prefill --async-scheduling --speculative-config '{"method":"dspark","num_speculative_tokens":'${MTP_NUM_TOKENS:-3}',"draft_sample_method":"probabilistic"}' --tokenizer-mode deepseek_v4 --distributed-executor-backend mp --moe-backend flashinfer_b12x --enable-flashinfer-autotune --tool-call-parser deepseek_v4 --enable-auto-tool-choice --reasoning-parser deepseek_v4 --reasoning-config '{"reasoning_parser":"deepseek_v4","reasoning_start_str":"<think>","reasoning_end_str":"</think>"}' --default-chat-template-kwargs '{"thinking":false}' --generation-config vllm --nnodes 2 --node-rank ${NODE_RANK} --master-addr ${MASTER_ADDR} --master-port ${MASTER_PORT:-25000} ${HEADLESS:+--headless} COMPOSEbashBoth nodes need docker-compose.yml at this identical path — start.sh invokes docker compose on the worker over SSH from the same directory. The .env and the scripts in the next steps live on the HEAD only (start.sh ships the .env to the worker for you).
- 3
Write the .env (HEAD node only)
Only the head needs this. start.sh ships it to the worker and overrides the node-specific values (NODE_RANK, HEADLESS, VLLM_HOST_IP, NCCL_IB_GID_INDEX) at the compose call, so edit it here and nowhere else. Set MASTER_ADDR / WORKER_HOST for your pair.
# on the HEAD node, in ~/dspark-v4-flash cat > .env <<'ENVFILE' # DeepSeek V4 Flash DSpark — dual DGX Spark (TP2, 1M context) # # Copy to .env on the HEAD node only. start.sh ships it to the worker and # overrides the node-specific values (NODE_RANK, HEADLESS, VLLM_HOST_IP, # NCCL_IB_GID_INDEX) at the compose call, so edit it here and nowhere else. # ---- cluster --------------------------------------------------------------- MASTER_ADDR=192.168.100.1 WORKER_HOST=192.168.100.2 MASTER_PORT=25000 # Where this checkout lives on the worker. Blank = same path as the head. WORKER_DIR= # ---- fabric ---------------------------------------------------------------- # NOTE: NCCL_IB_GID_INDEX is deliberately absent. The RoCEv2 IPv4 GID index # differs per node and drifts across reboots (ours: head 3, worker 6), so # start.sh resolves it from sysfs on each node at launch. Do not hardcode it. NCCL_IB_HCA=rocep1s0f1 NCCL_SOCKET_IFNAME=enp1s0f1np1 NCCL_DEBUG=WARN # ---- model + cache --------------------------------------------------------- DSPARK_MODEL=deepseek-ai/DeepSeek-V4-Flash-DSpark SERVED_MODEL_NAME=deepseek-v4-flash-dspark HF_CACHE=${HOME}/.cache/huggingface # Keep 0 until the full 48-shard tree is cached on BOTH nodes, then set 1 so a # later run can never silently re-fetch 156 GB. HF_HUB_OFFLINE=0 TRANSFORMERS_OFFLINE=0 # The runtime image. See README for why a stock vLLM cannot serve this # checkpoint — this pin is load-bearing, not convenience. DSPARK_VLLM_IMAGE=ghcr.io/anemll/dspark-vllm-gx10:0.1.1 VLLM_PORT=8888 # ---- serving profile ------------------------------------------------------- # Measured 2026-07-19 on 2x DGX Spark. See deploy README / the recipe page. MAX_MODEL_LEN=1048576 # Leave at 6. The compose file derives --max-cudagraph-capture-size from # MAX_NUM_SEQS x (MTP_NUM_TOKENS+1); dropping it to 2 shrinks capture 24 -> 8 # and costs ~1/3 of decode. This is the one place "small max-num-seqs for a # single user" is wrong. MAX_NUM_SEQS=6 MAX_NUM_BATCHED_TOKENS=8192 GPU_MEMORY_UTILIZATION=0.85 MTP_NUM_TOKENS=3 ENVFILE $EDITOR .env # set MASTER_ADDR / WORKER_HOST for your pairbashDo not set NCCL_IB_GID_INDEX here. start.sh resolves it per node from sysfs at launch, because the index differs between nodes and drifts across reboots.
- 4
Write start.sh and stop.sh (HEAD node only)
These run from the head. start.sh preflights free memory on both nodes, resolves each node's RoCEv2 GID index from sysfs, brings the worker's container up before the head's, then polls for the API. stop.sh tears both down and confirms the memory came back. The orchestration derives from the public MiaAI-Lab/DeepSeek-v4-Flash-DSpark-2x-DGX-Spark; runtime image by Anemll.
# on the HEAD node, in ~/dspark-v4-flash cat > start.sh <<'START' #!/usr/bin/env bash # Bring up DeepSeek V4 Flash DSpark across two DGX Sparks (TP=2, 1M context). # Run from the HEAD node. Starts the worker's container over SSH first, then # the head's, then polls for the API. # # Differences from the upstream MiaAI-Lab scripts this was derived from: # - resolves the RoCEv2 GID index PER NODE from sysfs (they differ between # nodes and drift across reboots; upstream reuses one value and NCCL dies) # - preflights free memory on both nodes (this needs ~115 of 121 GiB each) # - no hardcoded in-image interpreter path set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$DIR" [ -f .env ] || { echo "FATAL: no .env here — copy .env.example to .env first."; exit 1; } set -a; . ./.env; set +a : "${MASTER_ADDR:?set in .env}" : "${WORKER_HOST:?set in .env}" : "${NCCL_IB_HCA:=rocep1s0f1}" PROJECT=ds4-flash-dspark WORKER_DIR="${WORKER_DIR:-$DIR}" COMPOSE=(docker compose -p "$PROJECT" --env-file .env -f docker-compose.yml) # ---- preflight: both nodes must be essentially empty ------------------------ # ~78 GiB of weights + ~17 GiB of KV per node leaves no room for a leftover # Ray cluster or vLLM server. A stale job shows up ten minutes later as an # allocation failure deep into startup, so check now. for host in "$MASTER_ADDR" "$WORKER_HOST"; do avail=$(ssh "$host" "free -g | awk '/^Mem:/{print \$7}'") echo "preflight: $host has ${avail} GiB available" if [ "$avail" -lt 100 ]; then echo "FATAL: $host has only ${avail} GiB available; need ~115." ssh "$host" 'pgrep -af "bin/vllm|raylet" | head' || true exit 1 fi done # ---- resolve each node's RoCEv2 GID index ---------------------------------- # The IPv4 RoCEv2 GID index is per-node and moves across reboots and link # events. Ours were 3 (head) and 6 (worker) simultaneously. gid_index() { # $1 = ssh target / fabric ip local ip="$1" hex hex=$(printf '%02x%02x:%02x%02x' $(echo "$ip" | tr . ' ')) ssh "$ip" "for g in /sys/class/infiniband/${NCCL_IB_HCA}/ports/1/gids/*; do \ i=\${g##*/}; \ [ \"\$(cat /sys/class/infiniband/${NCCL_IB_HCA}/ports/1/gid_attrs/types/\$i 2>/dev/null)\" = 'RoCE v2' ] || continue; \ case \$(cat \$g) in *ffff:${hex}) echo \$i; break;; esac; done" } GID_HEAD=$(gid_index "$MASTER_ADDR") GID_WORKER=$(gid_index "$WORKER_HOST") [ -n "$GID_HEAD" ] && [ -n "$GID_WORKER" ] || { echo "FATAL: could not resolve RoCEv2 GID index (head='$GID_HEAD' worker='$GID_WORKER')." echo "Check the fabric is up: ibstat | grep -A3 ${NCCL_IB_HCA}" exit 1 } echo "RoCEv2 GID index: head=$GID_HEAD worker=$GID_WORKER" # ---- worker first, then head ------------------------------------------------ echo "== starting worker on $WORKER_HOST ==" scp -q .env "$WORKER_HOST:$WORKER_DIR/.env" ssh "$WORKER_HOST" "cd $WORKER_DIR && \ NODE_RANK=1 HEADLESS=1 \ VLLM_HOST_IP='$WORKER_HOST' NCCL_IB_GID_INDEX='$GID_WORKER' \ docker compose -p '$PROJECT' --env-file .env -f docker-compose.yml up -d" echo "== starting head on $MASTER_ADDR ==" NODE_RANK=0 HEADLESS= \ VLLM_HOST_IP="$MASTER_ADDR" NCCL_IB_GID_INDEX="$GID_HEAD" \ "${COMPOSE[@]}" up -d # ---- wait for the API ------------------------------------------------------- # Cold start is ~11 min: ~3 min of shard loading per rank, then profiling, # flashinfer autotune and cudagraph capture. echo "== waiting for API on :${VLLM_PORT:-8888} (cold start ~11 min) ==" for _ in $(seq 1 120); do if curl -sf -m 4 "http://127.0.0.1:${VLLM_PORT:-8888}/v1/models" >/dev/null 2>&1; then echo "API is up." docker logs "$PROJECT" 2>&1 | grep -E 'Available KV cache|GPU KV cache size|Maximum concurrency' | tail -3 || true echo echo "Steady-state memory (plan against THIS, not the mid-load figure):" for host in "$MASTER_ADDR" "$WORKER_HOST"; do ssh "$host" "echo -n ' $host: '; free -g | awk '/^Mem:/{print \$3\" GiB used, \"\$7\" available\"}'" done echo echo "Now warm the model before measuring anything — expert planes fault" echo "in from NVMe on first touch. Six full generations is usually enough." exit 0 fi sleep 10 done echo "FATAL: API did not come up. Recent errors:" docker logs --since 25m "$PROJECT" 2>&1 | grep -iE 'error|traceback|failed' | tail -20 echo echo "NOTE: 'docker compose logs' replays PREVIOUS containers of the same" echo "project — check timestamps before debugging a stack trace from days ago." exit 1 START cat > stop.sh <<'STOP' #!/usr/bin/env bash # Tear down DeepSeek V4 Flash DSpark on both nodes. Run from the HEAD node. set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$DIR" [ -f .env ] || { echo "FATAL: no .env here."; exit 1; } set -a; . ./.env; set +a PROJECT=ds4-flash-dspark WORKER_DIR="${WORKER_DIR:-$DIR}" echo "== stopping head ==" docker compose -p "$PROJECT" --env-file .env -f docker-compose.yml down --remove-orphans || true echo "== stopping worker on ${WORKER_HOST} ==" ssh "$WORKER_HOST" "cd $WORKER_DIR && \ docker compose -p '$PROJECT' --env-file .env -f docker-compose.yml down --remove-orphans" || true # Confirm the memory actually came back — a half-dead rank holding ~100 GiB is # the usual reason the next run fails deep into startup. echo echo "Memory after teardown:" for host in "$MASTER_ADDR" "$WORKER_HOST"; do ssh "$host" "echo -n ' $host: '; free -g | awk '/^Mem:/{print \$7\" GiB available\"}'" done STOP chmod +x start.sh stop.shbash - 5
Stage the checkpoint on both nodes
Every TP rank reads all 48 shards to extract its slice, so both nodes need the complete ~156 GB tree. Download once and mirror over the RoCE link rather than pulling twice over the internet — on our pair the worker's default route is wifi, which makes a direct download painfully slow.
# on the head hf download deepseek-ai/DeepSeek-V4-Flash-DSpark # mirror to the worker over the direct RoCE link rsync -a --partial --inplace \ -e 'ssh -c aes128-gcm@openssh.com -o Compression=no' \ ~/.cache/huggingface/hub/models--deepseek-ai--DeepSeek-V4-Flash-DSpark/ \ 192.168.100.2:~/.cache/huggingface/hub/models--deepseek-ai--DeepSeek-V4-Flash-DSpark/bashVerify both sides report the same snapshot revision with 48 shards and zero *.incomplete files. Keep HF_HUB_OFFLINE=0 until this is done on both nodes, then set it to 1 so a later run can never silently re-fetch 156 GB.
- 6
Resolve each node's RoCEv2 GID index
The IPv4 RoCEv2 GID index differs per node and moves across reboots and link events. On our pair it is 3 on the head and 6 on the worker simultaneously. Upstream ships a single NCCL_IB_GID_INDEX and reuses it on both nodes, which kills NCCL at init; the env below adds a separate worker value. Re-check this after any reboot.
for h in 192.168.100.1 192.168.100.2; do echo -n "$h -> " ssh $h "show_gids | awk '/rocep1s0f1/ && /v2/ && /$h/ {print \$3}'" done # ours: 192.168.100.1 -> 3 ; 192.168.100.2 -> 6bashIf this prints nothing, the fabric link is down — check `ibstat` for LinkUp: true before going further.
- 7
Review the serving profile
The values that produced every number on this page are already the defaults in .env.example. The two worth understanding before you change them are MAX_NUM_SEQS and MAX_NUM_BATCHED_TOKENS — both look like obvious single-user tuning targets and both make things worse. See the parameters below.
# ~/dspark-v4-flash/.env MAX_MODEL_LEN=1048576 # the model's own ceiling MAX_NUM_SEQS=6 # NOT 1-2 — see keyParams MAX_NUM_BATCHED_TOKENS=8192 GPU_MEMORY_UTILIZATION=0.85 MTP_NUM_TOKENS=3 # DSpark draft depthbash - 8
Start the cluster from the head
The script starts the worker's container over SSH first, then the head's, then polls for the API. Expect roughly 11 minutes cold: about 3 minutes of shard loading per rank, then profiling, flashinfer autotune and graph capture.
cd ~/dspark-v4-flash ./start.sh # watch the numbers that matter docker logs -f ds4-flash-dspark 2>&1 \ | grep -E 'Available KV cache|GPU KV cache size|Maximum concurrency|startup complete'bashExpected: 'Available KV cache memory: 17.21 GiB', 'GPU KV cache size: 2,555,830 tokens', 'Maximum concurrency for 1,048,576 tokens per request: 2.44x'.
- 9
Confirm steady state, then warm the model
Check resident memory only after the API answers — this is the figure to plan against. Then warm up: expert planes fault in from NVMe on first touch, so the first requests after a cold start are several times slower than settled behaviour. Ours settle after about six passes.
for h in 192.168.100.1 192.168.100.2; do ssh $h 'free -g | head -2'; done # expect: head ~115 GiB used / ~5 free, worker ~111 / ~9 for i in $(seq 1 6); do curl -s http://127.0.0.1:8888/v1/chat/completions \ -H 'Content-Type: application/json' \ -d '{"model":"deepseek-v4-flash-dspark", "messages":[{"role":"user","content":"Write 200 words about memory bandwidth."}], "max_tokens":256,"temperature":0}' \ -o /dev/null -w "warm $i: %{time_total}s\n" donebashStop when consecutive passes agree within a few percent (ours: 5.7-6.4 s). Benchmarking before this point measures NVMe, not the model.
- 10
Stop the cluster
Run from the head; it tears down both nodes. Confirm memory is actually released before starting other work.
cd ~/dspark-v4-flash ./stop.sh for h in 192.168.100.1 192.168.100.2; do ssh $h 'free -g | head -2'; donebash
Key vLLM parameters
| Parameter | Value | Purpose |
|---|---|---|
| DSPARK_VLLM_IMAGE | ghcr.io/anemll/dspark-vllm-gx10:0.1.1 | Load-bearing, not packaging convenience. It carries vLLM 0.25.2.dev0+g752a3a504 (unreleased) with three things a stock vLLM lacks: the nvfp4_ds_mla KV dtype, the DSpark speculative proposer as it works on CUDA, and FP4 MoE kernels compiled for sm_121 against CUDA 13. Re-checked against stock vLLM 0.26.0 on 2026-07-26: it now loads the checkpoint (the three 0.24.0 walls are gone) but still cannot serve a single user, because its sm120 sparse-MLA decode dispatch is broken. Both attempts are in troubleshooting |
| --tensor-parallel-size | 2 | The 155 GiB checkpoint does not fit one 121 GiB node — TP2 puts ~78 GiB of weights on each. Pipeline parallelism is not used: TP keeps both ranks working on every token, which matters because decode here is latency-bound, not throughput-bound |
| --kv-cache-dtype | nvfp4_ds_mla | The DeepSeek MLA KV layout at NVFP4. This is what makes 1M context reachable at all: 17.21 GiB/rank buys a 2,555,830-token pool. FP8 KV would roughly halve the pool and put 1M out of reach |
| --speculative-config | {"method":"dspark","num_speculative_tokens":5,"draft_sample_method":"probabilistic"} | The single biggest decode lever, and the drafter already ships in the checkpoint (~1 GiB/rank). num_speculative_tokens MUST be at least 5: the checkpoint declares dspark_block_size 5, the drafter emits 5-token blocks, and a smaller k truncates one — vLLM 0.26.0 rejects k<5 outright with 'Smaller values produce incorrect output', while the 0.25.2 image pinned here accepts it silently. Measured at k=5, warm, 2048-token prompt: prose 42.0 tok/s at 0.338 acceptance (2.69 tokens/step), code 76.0 tok/s at 0.825 (5.12 tokens/step). Going from k=3 to k=5 LOWERS the acceptance rate (more positions to get right) but raises tokens per step, which is what actually drives throughput — code went 3.54 to 5.12 tokens/step. It raises TTFT slightly since the draft runs every step, a good trade for one user generating long outputs |
| --max-model-len | 1048576 | The model's max_position_embeddings, so this is a hard ceiling rather than a tuning choice. The pool holds 2.44x this, and that surplus cannot be converted into more context — unusual for a Spark recipe, where max-model-len is normally the dial you push until concurrency nears 1.0x |
| --max-num-seqs | 6 | Leave this at 6 even though we serve one user. The compose file derives --max-cudagraph-capture-size from MAX_NUM_SEQS x (MTP_NUM_TOKENS+1), so lowering it to 2 shrinks graph capture and drops decode by roughly a third (measured at k=3: code 62.5 to 46.8 tok/s, prose 44.2 to 26.0, and far noisier). This is the one place the usual 'keep max-num-seqs at 1-2 for a single user' advice is wrong. Note the coupling with k: at k=5 this same formula gives capture 36, which is why the utilization is trimmed to 0.78 |
| --max-num-batched-tokens | 8192 | Chunked-prefill chunk size. Raising it to 16384 to speed up long-context prefill improved TTFT at 275K by only ~16% (199 s to 168 s) while costing the decode above, because it forces max_num_seqs down to stay inside memory. Not worth it — startup warns 'max_num_scheduled_tokens is set to 8180', which is expected here, not a problem to fix |
| --gpu-memory-utilization | 0.78 | A weak lever on GB10 in general (the utilization math counts system-wide usage on unified memory), but it is the knob we use to pay for k=5. At k=5 the cudagraph capture size becomes max_num_seqs x (k+1) = 36 rather than 24, and that graph memory has to come from somewhere; dropping 0.85 to 0.78 takes it from KV, which costs nothing real because the pool is still 1.21x the largest request the model can accept. At 0.78 this lands 8.54 GiB/rank of KV and leaves ~12 GiB free on the head at steady state. Use --kv-cache-memory-bytes instead if you want a deterministic KV budget |
| --block-size | 256 | Large PagedAttention blocks suit million-token single requests — fewer block-table lookups per step, and fragmentation is irrelevant when one sequence owns the pool. This value is safe on the pinned image, but do NOT carry it to a stock vLLM: flashinfer's sm120 sparse-MLA decode kernel only dispatches at page_block_size 64 (_DECODE_DSV4_PAGE_BLOCK_SIZE), and at 256 the decode call silently falls through to the prefill orchestrator and dies on 'Check failed: num_tokens > 64'. Use --block-size 64 there. See troubleshooting |
| --moe-backend | flashinfer_b12x | Works here because the image is built for sm_121 against CUDA 13. This is image-specific: on a stock vLLM the b12x path is a known dead end on GB10, so do not port this flag to a locally built stack without testing |
| NCCL_IB_GID_INDEX / WORKER_NCCL_IB_GID_INDEX | 3 / 6 (resolve per node) | The RoCEv2 IPv4 GID index differs per node and drifts across reboots. Upstream reuses one value for both nodes; hardcoding a single index kills NCCL at init with 'unhandled system error' |
| --tokenizer-mode / --reasoning-parser / --tool-call-parser | deepseek_v4 | V4-specific tokenizer and parsers. Without the reasoning parser the <think> block is returned as ordinary content instead of the reasoning_content field |
API usage
Chat completion
The model card recommends temperature 1.0 / top_p 1.0 for general use. Use temperature 0 when measuring speculative decoding — draft acceptance falls as sampling temperature rises. The server ships with thinking disabled by default (--default-chat-template-kwargs '{"thinking":false}'); pass chat_template_kwargs to turn it on.
curl -s http://127.0.0.1:8888/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"deepseek-v4-flash-dspark",
"messages":[{"role":"user","content":"What is 47 x 89?"}],
"temperature":0, "max_tokens":64}'
# enable the reasoning trace (returned in choices[].message.reasoning_content)
# "chat_template_kwargs": {"thinking": true}
# Think Max mode wants >= 384K of context to be useful.bashMeasure single-stream decode correctly
Derive tok/s from the server's own usage.completion_tokens and the first-token/last-token timestamps — NEVER by counting SSE frames. DSpark bundles up to 4 tokens (3 draft + 1 bonus) into a single chunk, so frame-counting undercounts by roughly 4x. The tell is a throughput number that stays identical whether acceptance is 0.85 or 0.53 — that flat figure is the step rate, not the token rate.
curl -s http://127.0.0.1:8888/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"deepseek-v4-flash-dspark",
"messages":[{"role":"user","content":"Explain paged attention in detail."}],
"max_tokens":512, "temperature":0, "stream":true,
"stream_options":{"include_usage":true}}'
# decode_tps = (completion_tokens - 1) / (t_last_token - t_first_token)
# measured at k=5, warm, single-stream, temperature 0 (2048-token prompt):
# prose 42.0 tok/s | code 76.0 tok/sbashRead the speculative-decoding counters
This is what explains the tok/s number: decode tok/s is the step rate multiplied by tokens accepted per step. If decode looks slow, check acceptance before suspecting the fabric or memory. Acceptance RATE falls as k rises while tokens/step keeps climbing, so read both — the rate alone is misleading.
curl -s http://127.0.0.1:8888/metrics | grep -E \
'spec_decode_num_(drafts|draft_tokens|accepted_tokens)_total'
# acceptance = accepted_tokens / draft_tokens
# tokens/step = 1 + accepted_tokens / drafts
# measured, per workload, at k=5 (metric deltas around a single harness scenario):
# code : acceptance 0.825, 5.12 tok/step -> 76.0 tok/s
# prose : acceptance 0.338, 2.69 tok/step -> 42.0 tok/sbashLong-context behaviour
Decode speed is essentially flat as context grows; what grows is time-to-first-token. Budget for prefill, not for slow generation. Prefill runs at 1,400-2,100 tok/s and degrades gently with length.
prompt tokens | TTFT | prefill tok/s | decode tok/s (prose)
--------------+---------+---------------+---------------------
508 | 0.48 s | 1,066 | 40.5
2,050 | 1.16 s | 1,775 | 42.0
8,183 | 4.04 s | 2,027 | 43.1
# measured single-stream, temperature 0, warm, k=5.
# Decode is flat-to-slightly-rising with prompt length; TTFT is what grows.
# Earlier measurements out to a 275K prompt (at k=3) showed TTFT ~199 s
# while decode stayed in the 40s, so budget prefill, not generation.textTroubleshooting
- num_speculative_tokens=3 produces incorrect output (fixed 2026-07-26)
- This recipe shipped k=3 until 2026-07-26 and that was wrong. The checkpoint's config.json declares dspark_block_size 5 — the DSpark drafter emits 5-token blocks, so asking for 3 truncates one. vLLM 0.26.0 added a hard validation and refuses to start: 'pydantic_core._pydantic_core.ValidationError: 1 validation error for SpeculativeConfig / Value error, DSpark requires num_speculative_tokens >= dspark_block_size (5); got 3. Smaller values produce incorrect output. Use num_speculative_tokens=5 or larger (e.g. 7).' The pinned 0.25.2 image predates that check and runs k=3 silently, which is how this went unnoticed — a truncated draft still gets verified, so throughput looks plausible and nothing fails. Set MTP_NUM_TOKENS=5. Two knock-on effects, both measured: (1) capture size is MAX_NUM_SEQS x (k+1), so it goes 24 to 36 and you must pay for it — we dropped GPU_MEMORY_UTILIZATION 0.85 to 0.78, which took KV from 17.21 to 8.54 GiB/rank and the pool from 2,555,830 to 1,268,110 tokens, still 1.21x at full 1M context. (2) throughput moved differently per workload, and only a MATCHED-CONTEXT comparison shows it. Comparing both arms at the same 8192-token prompt: code went 62.5 to 73.2 tok/s (+17.1%), while prose went 44.2 to 43.1 (-2.5%, essentially flat). The headline decodeTps on this page dropped 44.2 to 42.0 only because the measurement context also moved from 8192 to the standard 2048 prompt — those two figures are NOT a k=3-vs-k=5 comparison. Underneath, acceptance rate fell (0.847 to 0.825 code, 0.526 to 0.338 prose) while tokens per step rose (3.54 to 5.12 code, 2.58 to 2.69 prose): rate and tokens/step move in opposite directions as k rises, and only tokens/step drives throughput. Code benefits because its draft acceptance stays high enough to cash in the longer block; prose does not.
- Can stock vLLM 0.26.0 replace the pinned image? (answered 2026-07-27: no, and it is not a tuning problem)
- No, and the reason is now root-caused rather than guessed: two hard-coded block sizes in released code contradict each other on this hardware. Everything below was re-tested on 2026-07-27 against stock vLLM 0.26.0 (TP2 over Ray, both Sparks). What still works is unchanged and genuinely good — the checkpoint loads end to end, DeepGEMM handles the ue8m0 block scales, the in-checkpoint drafter loads ('DSpark draft model loaded: 96 params'), it auto-selects DEEPGEMM_MXFP4, and weights come to 79.22 GiB per rank. The engine dies on --block-size, and sweeping that flag is the whole experiment because every remaining wall is a function of it. (1) --block-size 64 is arithmetically impossible. The assertion in _get_kv_cache_groups_uniform_groups that was previously reported as the wall is NOT the bind — adding --disable-hybrid-kv-cache-manager clears it and the engine reaches KV sizing. It then dies with 'ZeroDivisionError: integer division or modulo by zero' inside UniformTypeKVCacheSpecs.max_memory_usage_bytes, because MLAAttentionSpec.storage_block_size is block_size // compress_ratio and this config's compress_ratios list contains 128: 64 // 128 == 0, a zero-byte page. Any block size under 128 is dead on this checkpoint regardless of what the kernels want. (2) --block-size 128 gets past KV sizing and dies in select_common_block_size with 'ValueError: No common block size for 128.' Reading the installed classes explains it: DeepseekV4IndexerBackend.get_supported_kernel_block_sizes() returns exactly [256] — a bare int, not a MultipleOf, so 128 has no divisor to fall back to. (DeepseekV32IndexerBackend returns [64]; do not carry a V3.2 intuition across to V4.) (3) --block-size 256 is therefore the only value vLLM will accept, and it is the one the decode kernel refuses. KV sizes cleanly at 256 — measured 202,311 tokens from a 10 GiB pin, 1.54x concurrency at a 131,072 max-model-len — then the first forward pass fails with 'Check failed: num_tokens > 64 (5 vs. 64) : Decode (num_tokens <= 64) must go through sparse_mla_sm120_decode_dsv3_2 or sparse_mla_sm120_decode_dsv4; got num_tokens=5'. flashinfer's mla/_sparse_mla_sm120.py hard-codes _DECODE_DSV4_PAGE_BLOCK_SIZE = 64 and _decode_dsv4_dispatchable() requires page_block_size == 64, so at 256 the decode kernel is not dispatchable and the call falls through to the prefill orchestrator, whose first act is that assertion — and single-user decode is always well under 64 tokens. (4) There is no second backend to try. On GB10 (sm_121, device_capability.major == 12) vLLM's own priority list in platforms/cuda.py is [TRITON_MLA, FLASHINFER_MLA_SPARSE_SM120]; TRITON_MLA is dense MLA and is rejected for this sparse model, so FLASHINFER_MLA_SPARSE_SM120 is the only sparse-MLA path on this hardware. VLLM_ATTENTION_BACKEND has nothing to point at. Net: 256 is required by the DeepSeek V4 indexer, 64 is required by the sm120 decode kernel, both are hard-coded integers, and no flag reconciles them. Keep the pinned image. Do not spend another run sweeping flags — the next move is upstream (or a vendored patch), not configuration. One more thing worth knowing: --disable-hybrid-kv-cache-manager is a diagnostic, never a serving flag here. It unifies the sliding-window and compressed-state layers up to full-length KV and the bill is brutal — measured, it wants 40.07 GiB of KV for a 131,072-token max_model_len (~320 KiB/token) against this recipe's 8.54 GiB for 1,268,110 tokens (~7.06 KiB/token), about 45x per token. The 1M context would need ~320 GiB of KV.
- Is it worth upgrading for the DeepSeek-V4 speedups in the vLLM 0.26.0 release notes?
- Mostly not, on this hardware. Of the six DSv4 performance PRs in those notes, three are AMD-only and one is Intel: #48519 (sparse attention prefill), #48788 (sparse decode occupancy, gfx950) and #46275 (split sparse decode, gfx942) are all tagged [ROCm], and #47677 (DSpark spec decode) is [XPU]. Only three touch the CUDA path we run: #48660 specialized routing kernel (2.94% E2E TPOT), #47463 fused_topk_bias (1.5-2x on that kernel alone) and #48137 removing a redundant repeat/copy (1.8% E2E TPOT, and it edits deepseek_v4/nvidia/model.py directly). So the realistic ceiling is roughly 4.7% end-to-end, which is not worth trading the nvfp4_ds_mla KV dtype for — and in any case the decode dispatch bug above makes it unreachable today.
- Startup takes 45+ minutes instead of ~11, with the shard progress bar restarting
- You purged the page cache first. This checkpoint's loader makes TWO full passes over the 155 GiB tree — the log shows 'Loading weights took 160.21 seconds' for the main model, then 'Using Eagle3 auxiliary layers from config: (41, 42, 43)' followed by a second 0-to-48 shard pass for the DSpark drafter. A progress bar that appears to restart is that second pass, NOT a crash. A warm cache is therefore worth an order of magnitude: measured 2026-07-26, a warm boot reached KV sizing in about 4.5 minutes while the identical config after a purge took over 45 and was still going. Purging is only needed to satisfy the startup free-memory check, and pinning --kv-cache-memory-bytes skips that check entirely ('reserved 12.0 GiB memory for KV Cache as specified by kv_cache_memory_bytes config and skipped memory profiling'). So when you pin KV, do not purge. Note also that Ray forwards worker logs out of order, so a progress bar that appears to jump backwards is usually stale buffered output rather than a real restart — check timestamps before diagnosing.
- Serving dies silently right after 'GPU KV cache size', no traceback
- The node ran out of unified memory during cudagraph capture. Two tells: 'oom-guard.sh TRIP: MemAvailable=5193 MiB < 8192 MiB — killing vLLM/Ray workers' in journalctl --user, and 'NVRM: ... Out of memory [NV_ERR_NO_MEMORY]' in journalctl -k. Budget it as a sum: 79.22 GiB of weights per rank plus the KV pin plus graph capture has to fit under ~113 GiB of usable pool. Measured 2026-07-26 at k=5: an 18 GiB KV pin (1,873,590 tokens, 1.79x concurrency at 1M) plus capture size 32 did not fit; 12 GiB (1,249,060 tokens, 1.19x) did. Since a single user cannot use concurrency above ~1.0x anyway, spend the surplus rather than hoarding it — the 1.79x pool was 6 GiB of KV bought and never used.
- Can a stock vLLM serve this checkpoint instead of the pinned image?
- No — we tested it on 2026-07-19 with vLLM 0.24.0 (torch 2.11.0+cu130, TP2 over Ray) and hit three walls, each behind the last. (1) DeepGEMM: 'RuntimeError: Assertion error (/workspace/.deps/deepgemm-src/csrc/apis/layout.hpp:59): Unknown SF transformation' — the checkpoint's FP8 dense weights use scale_fmt ue8m0 block scales it cannot lay out. (2) With VLLM_USE_DEEP_GEMM=0 it gets into weight loading and dies at "KeyError: 'model.layers.43.mtp_block.main_norm.weight'" in vllm/models/deepseek_v4/nvidia/mtp.py — 0.24.0's DeepSeek-V4 MTP does not know the DSpark drafter's parameter layout. (3) With speculative decoding off entirely it reaches the first forward and fails in CUTLASS: 'dispatch_scaled_mm, scaled_mm_helper.hpp:17' — no sm_121 kernel for this dtype combination. Separately, 0.24.0 has only fp8_ds_mla (not nvfp4_ds_mla), which roughly halves the KV pool and puts 1M context out of reach, and has no dspark speculative method at all.
- Decode drops by a third after lowering --max-num-seqs for single-user serving
- The compose file computes --max-cudagraph-capture-size as MAX_NUM_SEQS x (MTP_NUM_TOKENS+1). Setting MAX_NUM_SEQS=2 shrinks capture from 24 to 8, so decode falls off the captured graphs and becomes both slower and much noisier — we measured code 62.5 to 46.8 tok/s and prose 44.2 to 26.0 tok/s, with run-to-run spread widening from a few percent to nearly 2x. The general advice to keep max-num-seqs at 1-2 for one user does not apply when capture size is derived from it. Leave it at 6.
- Raising --max-num-batched-tokens to speed up long-context prefill
- Tried and reverted. 16384 improved TTFT at a 275K prompt from 199 s to 168 s (~16%) but forced max-num-seqs down to fit memory, costing the decode regression above. The KV pool also shrank from 2,555,830 to 1,570,685 tokens (2.44x to 1.50x concurrency). Net loss for a single user — the startup warning 'max_num_scheduled_tokens is set to 8180 based on the speculative decoding settings' is expected here, not a defect.
- NCCL dies at init with 'unhandled system error'
- Almost always the RoCEv2 GID index. It differs per node AND drifts — we have now seen the worker's index at 5 and at 6 on different days while the head stayed at 3, and a stale value fails at TP group init with a traceback ending 'RuntimeError: NCCL error: ...' inside init_model_parallel_group. Upstream ships only NCCL_IB_GID_INDEX and reuses it on both nodes; set WORKER_NCCL_IB_GID_INDEX separately. Better: do not store either value at all. Because .env.dspark holds a literal, it goes stale silently and you only find out ~2 minutes into a start. Resolve both indexes from sysfs at launch instead — walk /sys/class/infiniband/rocep1s0f1/ports/1/gids/*, keep the entries whose gid_attrs/types is 'RoCE v2' and whose GID embeds the node's IPv4, and export the result. Re-check after any reboot or link event.
- '/opt/env/bin/python: no such file or directory' from the helper scripts
- The upstream scripts hardcode /opt/env/bin/python, which exists only in the locally built vllm-dspark-runtime image. The prebuilt ghcr.io/anemll image ships /usr/bin/python3. Set DSPARK_PYTHON=/usr/bin/python3.
- The first model download fails or hangs
- The shipped .env sets HF_HUB_OFFLINE=1 and TRANSFORMERS_OFFLINE=1, which breaks a first download. Set both to 0 until the weights are fully cached on BOTH nodes, then flip them back to 1 so a later run can never silently re-fetch.
- Stack trace full of c10::Error frames right after starting, but the container is healthy
- `docker compose logs` replays the whole history of previous containers with the same project name, so a failure from days ago is re-printed at startup. Check the timestamps — a July-16 'W716' prefix is not today's run. Use `docker logs --since 10m <container>` to see only the current attempt.
- Allocation failure late in startup, or a node becomes pingable but SSH-dead
- The unified pool was overcommitted; the box is thrashing and userspace, including sshd and tailscaled, is starved. The usual cause is another job still resident — check both nodes for a leftover Ray cluster or vLLM server before starting. Recovery often does not need a power cycle: kill the job on the surviving peer (`ray stop --force`, then `pkill -9 -f 'ray::'`) to release cross-node pressure, and reach that peer over its fabric IP (192.168.100.x), not Tailscale, which will be down on the wedged node.
- 'RuntimeError: cancelled' in shm_broadcast, or 'Engine core initialization failed'
- The head lost a TP worker — this is not a comms bug. Look at the OTHER node's memory and logs; the root cause is almost always there. The traceback names the failing rank's IP (e.g. ip=192.168.100.2).
- Throughput looks identical whether the workload is code or prose
- You are counting SSE frames rather than tokens. DSpark packs up to 4 tokens per chunk, so frame-counting reports the step rate (~17.5/s), which really is near-constant across workloads. Use stream_options.include_usage and read usage.completion_tokens.
- First requests after startup are several times slower than expected
- Expert planes fault in from NVMe on first touch. Warm with about six full generations before measuring anything; ours settle to 5.7-6.4 s per 256-token pass. Never benchmark cold.
- 'free memory less than desired GPU memory utilization' at startup
- Page cache is squatting on the unified pool after staging 156 GB of weights. Purge it on BOTH nodes before serving (`~/Dev/vLLM-Moet/spark/purge-cache.py ~/.cache/huggingface`), or simply confirm ~117 GiB available on each node first.
Revision history
What has changed on this page since it was published, and what it measured. Newest first.
- compatibility
Root-caused why stock vLLM 0.26.0 cannot serve this checkpoint on GB10: two hard-coded block sizes that contradict each other.
Four TP2 boots sweeping --block-size. 64 is arithmetically impossible (compress_ratio 128 makes storage_block_size 0 -> ZeroDivisionError); 128 is accepted by no backend ('No common block size for 128'); 256 is the ONLY value DeepseekV4IndexerBackend accepts (get_supported_kernel_block_sizes() -> [256]) and is exactly the value flashinfer's sm120 decode kernel refuses (_DECODE_DSV4_PAGE_BLOCK_SIZE = 64), failing the first forward with 'Check failed: num_tokens > 64 (5 vs. 64)'. FLASHINFER_MLA_SPARSE_SM120 is the only sparse-MLA backend on sm_121, so there is nothing to fall back to. Upgrades the previous 'appears to block' finding to a proven one: this is an upstream defect, not a serving config. Also measured: --disable-hybrid-kv-cache-manager clears the old KV-group assertion but costs ~320 KiB/token (40.07 GiB for 131,072 tokens), ~45x this recipe's 7.06 KiB/token. No change to the published serve config.
- docs
Made the deployment self-contained — the recipe now inlines docker-compose.yml, .env, start.sh and stop.sh instead of cloning a repo.
No throughput or serving-config change: the compose file, flags and env are byte-for-byte identical. Only how you obtain the files changed — create ~/dspark-v4-flash on both nodes and paste the four files from the steps, rather than cloning. repoUrl now points at the public MiaAI-Lab upstream the orchestration derives from.
- correctnessaction needed
Raised DSpark speculative decoding from k=3 to k=5, the value the checkpoint actually requires.
The checkpoint declares dspark_block_size 5, so the drafter emits 5-token blocks and k=3 truncated one — vLLM 0.26.0 rejects k<5 configs outright as 'producing incorrect output', while the 0.25.2 image pinned here accepted it silently. Re-measured warm at a matched 8192-token prompt: code 62.5 → 73.2 tok/s (+17.1%), prose 44.2 → 43.1 (−2.5%, flat). Acceptance rate FELL (code 0.847 → 0.825, prose 0.526 → 0.338) while tokens per step ROSE (3.54 → 5.12, 2.58 → 2.69) — tokens/step is what drives throughput.
- performance
Trimmed --gpu-memory-utilization from 0.85 to 0.78 to pay for k=5's larger cudagraph capture.
Capture size is max_num_seqs × (k+1), so it went 24 → 36. KV dropped 17.21 → 8.54 GiB/rank and the pool 2,555,830 → 1,268,110 tokens. No real cost: the pool is still 1.21× the full 1,048,576-token context, and 1M is the model's max_position_embeddings, so surplus pool can never become more context. Steady state 108 GiB head / 105 GiB worker; weights measured at 79.17 GiB/rank.
- re-measured
Moved the headline decodeTps to the standard 2048-token prompt (was 8192).
decodeTps 44.2 @8192 → 42.0 @2048. This is NOT a k=3-vs-k=5 comparison — the measurement context moved at the same time. For the config delta, use the matched-context figures in the correctness entry above.
- compatibility
Re-tested stock vLLM 0.26.0 as a replacement for the pinned anemll image. Still blocked, but for a much narrower reason.
0.26.0 loads the checkpoint fine — the three walls that stopped 0.24.0 (DeepGEMM ue8m0 scales, the DSpark MTP KeyError, CUTLASS sm_121) are all gone, and it auto-selects DEEPGEMM_MXFP4. It dies on a block-size bind instead: flashinfer's sm120 sparse-MLA decode kernel only dispatches at page_block_size 64, but at 64 vLLM's _get_kv_cache_groups_uniform_groups asserts on this model's heterogeneous KV. Also, nvfp4_ds_mla is still image-only, so a stock serve would cost ~1.43× the KV bytes per token. Of the six DeepSeek-V4 speedups in the 0.26.0 notes, three are ROCm-only and one is XPU; the CUDA-relevant three total ~4.7% E2E TPOT.
- docs
Documented three operational traps hit while re-measuring.
The RoCEv2 GID index drifted again (worker 6 → 5, head still 3) and a stale literal in .env.dspark failed ~2 min into startup with 'RuntimeError: NCCL error' — resolve it from sysfs at launch instead of storing it. The loader makes TWO full passes over the 155 GiB tree (main model, then the drafter), so a restarting progress bar is normal and purging page cache before serving is counterproductive once KV is pinned. And `docker compose logs` replays a previous container's traceback into a new run — bound queries with --since and check timestamps.
Memory budget
The checkpoint is 155.43 GiB on disk (48 shards): FP4 routed-expert planes with FP8 attention and dense/shared layers. TP2 splits that to 79.17 GiB of weights per node (measured — 'Model loading took 79.17 GiB'), which is what makes two Sparks the minimum; it does not fit on one. Measured at k=5 with --gpu-memory-utilization 0.78: 8.54 GiB of KV per rank, a 1,268,110-token pool, with the head at 108 GiB and the worker at 105 GiB resident at STEADY STATE (the segments below subtract the ~4 GiB idle OS baseline, so they show the serving working set rather than the raw `free -g` figure). Read steady state, never the figure during weight loading — mid-load the box looks far emptier because the KV pool has not been allocated yet, and sizing anything against that will wedge it. Note the utilization is 0.78 rather than 0.85 on purpose: k=5 raises the cudagraph capture size to max_num_seqs x (k+1) = 36, and that extra graph memory has to come out of somewhere. Taking it from KV is free here, because KV is not the binding constraint — even the reduced pool is 1.21x the 1,048,576 tokens a single request can use, and 1,048,576 is the model's own max_position_embeddings, so surplus pool cannot be converted into more context. The planes/dense/draft split is apportioned from the on-disk size and the config's expert geometry rather than measured per-tensor; the weights total, KV pool and per-node resident figures are measured.