DeepSeek V4 Flash is a 284B-parameter MoE (256 routed experts, top-6 + 1 shared) with a novel long-context attention stack: a tiny 128-token raw sliding window per layer, plus per-layer compressed attention over 4:1 (CSA) or 128:1 (HCA) block-compressed keys, with a lightning indexer selecting the top-512 compressed rows on CSA layers. Residuals flow through 4-stream hyper-connections with Sinkhorn-normalized mixing. Advertised context: 1M tokens (YaRN ×16).
DeepSeek V4 has three whole-model executors:
--backend cuda: a direct-CUDA whole-model engine (TensorSharp.Backends.Cuda/Dsv4/Dsv4CudaEngine.cs), independent of ggml. Quantized weights stream from the GGUF shards straight into per-device arenas and are layer-split across every visible GPU, so a model larger than one GPU's VRAM is hosted across several.- GPU backends (
--backend ggml_cuda/ggml_vulkan): the native ggml executor described below. ggml ships DeepSeek-V4's four architecture-specific ops (the three hyper-connection ops and the lightning indexer) for CPU and CUDA only. On any other backendhc_pre/hc_postare built instead out of batchedmul_mat— they are exactly a contraction over the stream axis — so the whole layer stays on the accelerator;hc_comb(a 20-iteration Sinkhorn over a 4x4 matrix) and the lightning indexer still take the scheduler's CPU fallback, which is what is left of the Vulkan/CUDA gap. The decomposition is worth +34% prefill and +14% decode on Vulkan and is chosen automatically by a load-timeggml_backend_supports_opprobe (TS_DSV4_HC_NATIVE=0/1to A/B). --backend cpu: a 100% pure C# whole-model executor (TensorSharp.Models/Models/DeepSeek4/DeepSeek4CpuExecutor.cs) — no native dependencies at all. It serves the quantized weights straight from the memory-mapped GGUF shards and runs every op (hyper-connections with Sinkhorn mixing, the CSA/HCA block compressors, the lightning indexer, shared-K attention with sinks and inverse RoPE, sqrt-softplus MoE routing with hash layers) in managed SIMD code.
Both TensorSharp-native executors are built on the shared tensor stack
rather than private re-implementations: every buffer is an IAllocator-backed
Tensor (CudaAllocator pool on GPU, CpuAllocator on CPU), the linear
layers go through the one quantized-matmul router each backend already has
(CudaQuantizedOps.AddmmResidentToFloat32 /
ManagedQuantizedOps.AddmmQuantizedToFloat32), and generic math uses Ops
(Ops.RMSNorm, Ops.SiLUMulClamp, …). Only genuinely DSV4-specific compute —
hyper-connections, the block compressors, the lightning indexer + top-k, the
sink attention, and the grouped MoE kernels — lives in the DeepSeek V4 files.
The native whole-model executor
(TensorSharp.GGML.Native/ggml_ops_deepseek4.cpp):
- Loads the (split) GGUF directly and layer-splits the weights across every
visible CUDA GPU — a model larger than one GPU's VRAM is hosted across all
of them (the 128 GiB IQ4_XS build needs 2×80GB). This happens by default,
with no flag:
--tpis not what puts DSV4 on several cards, it switches the split from whole layers to Megatron column/row sharding within each layer.TS_DSV4_NGPUcaps how many devices the layer split uses. - Owns all DSV4 KV state on-device: raw SWA ring, CSA/HCA compressed-K caches, lightning-indexer cache, and the compressor state rings.
- Executes prefill/decode ubatches as single ggml graphs via
ggml_backend_sched, with flash attention (512-dim shared K=V head + attention sinks), fused hyper-connection ops, and a shape-signature graph cache so steady-state decode replays a captured CUDA graph.
The C# side (TensorSharp.Models/Models/DeepSeek4/DeepSeek4Model.cs) handles
GGUF metadata, the joyai-llm BPE pre-tokenizer, the DeepSeek V4 chat template
(<|User|>…<|Assistant|></think>, --think opens <think>), and sampling.
Both GPU engines — the native ggml executor and the direct-CUDA one — size the layer split against the VRAM each device actually has free right now, not against an equal share of the bytes. Two things follow from that.
The split is budget-proportional. Every device is filled to the same
fraction of its own free VRAM, minus a run-time reserve (the scheduler's
compute buffers, TS_DSV4_VRAM_RESERVE_MB, default 2048 MiB per device) and
minus the KV caches and compressor state rings the split can compute exactly.
A device with a display attached, or one already hosting another process, gets
proportionally fewer layers instead of the OOM an equal-bytes split would hand
it.
Routed experts spill to system RAM only when you ask. 91% of a V4 Flash checkpoint is routed-expert weights (137 of 151 GiB at UD-Q8_K_XL), so they are the only knob with enough range — but offload is OFF by default here exactly as it is for every other architecture. Choosing it silently is the wrong trade on a host that does have the VRAM: it moves tens of GiB of experts to the CPU and costs most of the decode throughput for no reason.
When the model does not fit, the loader says so and names the number that would work, instead of loading into an out-of-memory abort:
[dsv4] not enough VRAM: 150.7 GiB of weights plus this context's KV caches
against 152.9 GiB free across 7 device(s). Re-run with --n-cpu-moe 9
(moves the routed experts of the first 9 layer(s), 28.7 GiB, to system
RAM) or --cpu-moe to offload every layer.
Note what that example shows: the weights alone can look like they fit and still
not, because each device also holds its KV caches and the TS_DSV4_VRAM_RESERVE_MB
compute reserve (2048 MiB per device by default — 14 GiB across 7).
--n-cpu-moe N / --cpu-moe then keep the routed experts (ffn_gate_exps /
ffn_up_exps / ffn_down_exps) of the first N layers in host RAM and run their
mul_mat_id chain on the ggml CPU backend; the router, the norms, the attention
stack and the always-active shared expert stay on the accelerator, so only
[n_embd, n_tokens] activations cross the bus in each direction per offloaded
layer. Asking for more layers than needed trades decode speed for VRAM.
--backend cuda runs the same seam on its own kernels: the offloaded layers'
experts are multiplied by ManagedQuantizedOps and the weighted-sum epilogue
stays the device moe_scatter_add, so a layer is interchangeably resident or
offloaded. Its host FFN batches every selected expert's projection into one
parallel dispatch per stage — one per (expert, projection) spent more time in
the scheduler than in the arithmetic (226 → 105 ms per token).
Measured on 3×RTX A6000 48 GB (UD-Q8_K_XL, 151 GiB of weights against 139 GiB
of VRAM, 2-socket Xeon Gold 6342 under a 23.8-CPU cgroup quota; another process
held ~15 GiB during the ggml_cuda runs, which is why it offloaded 13 layers
against the direct engine's 9):
| Metric | ggml_cuda (8 offloaded) |
cuda (9 offloaded) |
ggml_vulkan (7 offloaded) |
|---|---|---|---|
| Decode | 14.7 tok/s | 8.9 tok/s | 6.5 tok/s |
| Prefill (1024–2048) | 206 tok/s | — | 138 tok/s |
| Prefill (8.7K) | 195 tok/s | 43.6 tok/s | 73 tok/s |
| Host cost per offloaded layer | ~5.4 ms/token | ~8.3 ms/token | ~5.4 ms/token |
Vulkan's remaining decode gap is not the expert offload — it is the per-layer
CPU round trip for hc_comb and the lightning indexer, the two ops the
decomposition deliberately leaves alone (decomposing a 20-iteration Sinkhorn
costs more tiny dispatches than the boundary it removes, and expanding the
indexer would materialize an [n_kv, n_head, n_tokens] score tensor that is
2.2 GB at a 1024-token prefill chunk).
Both engines run the drafter alongside an offloaded MoE, and the automatic split accounts for the drafter's own VRAM — loading one shifts exactly one more layer's experts to the host. Whether speculation still pays depends on how fast the host MoE is, because a verify batch of B tokens pulls B rows' worth of experts through it:
| Engine | no DSpark | + DSpark | acceptance |
|---|---|---|---|
ggml_cuda |
14.4 tok/s | 16.7 tok/s (1.16x) | — |
cuda |
8.9 tok/s | 6.2 tok/s (0.70x) | 51% |
So pair --draft-model with --backend ggml_cuda when experts are offloaded;
on the direct engine the managed host matmul is slow enough that the extra
verify rows cost more than the accepted tokens save.
Each offloaded layer reads ~80 MB of MXFP4 expert blocks per token, so the
loader offloading the minimum is what keeps this usable. Size the host pool
to the CPUs the process may actually use, not to nproc — see
TS_CPU_MOE_THREADS below.
# point --model at the FIRST shard of the split GGUF
TensorSharp.Cli --model DeepSeek-V4-Flash-UD-IQ4_XS-00001-of-00004.gguf \
--backend ggml_cuda --chat# direct-CUDA engine (no ggml): weights stream into per-GPU arenas, layer-split
# across every visible device
TensorSharp.Cli --model DeepSeek-V4-Flash-UD-IQ4_XS-00001-of-00004.gguf \
--backend cuda --chatMulti-turn chat reuses the KV cache across turns (pure append); prompts that rewind history re-prefill automatically (compressed caches cannot truncate).
# pure C# CPU inference (no native libraries; needs enough RAM for the caches,
# weights are served from the memory-mapped shards)
TensorSharp.Cli --model DeepSeek-V4-Flash-UD-IQ4_XS-00001-of-00004.gguf \
--backend cpu --chatDeepSeek ships a DSpark support module alongside the model (mtp.* in the
checkpoint): three DSV4 blocks that read the trunk's hidden states at layers
40-42 and propose a whole BLOCK of future tokens per step, plus a Markov head
that conditions each block position on the token before it and a confidence
head that predicts each position's acceptance probability. The trunk then
verifies the block in ONE batched forward and keeps the longest prefix its own
sampler would have drawn, so speculation is a speed path, not a quality change.
It is loaded as a separate drafter GGUF with --draft-model and engages for
greedy (--temperature 0) single-sequence generation on both GPU engines —
--backend cuda (direct-CUDA) and --backend ggml_cuda (the native ggml
executor). ggml_vulkan and cpu have no speculative path for this
architecture and log a warning if a drafter is configured.
Every single-sequence CLI generation path uses it: one-shot --input,
--multi-turn-jsonl, and the --interactive chat REPL (which streams the
accepted block token by token and reuses the cached prefix across turns). The
per-turn line reports what speculation did, e.g.
spec=window5/accepted330of502(66 %).
TensorSharp.Cli --model DeepSeek-V4-Flash-0731-UD-Q8_K_XL-00001-of-00005.gguf \
--backend ggml_cuda --draft-model DeepSeek-V4-Flash-0731-DSpark.gguf \
--input prompt.txt --max-tokens 200 --temperature 0
# Interactive chat, 4 GPUs
TensorSharp.Cli --model DeepSeek-V4-Flash-0731-UD-Q8_K_XL-00001-of-00005.gguf \
--backend ggml_cuda --draft-model DSpark-drafter-Q2K-Q8-0731.gguf \
--interactive --think --tp 4 --max-tokens 20000On the CLI, verification draws each row with whatever sampler the run
configured — argmax under --temperature 0, the chat sampler otherwise — so
speculation composes with /temp, /top-k, … in the REPL. One caveat under a
penalized sampler: DSpark proposes a whole block in one pass, so the
repetition/presence/frequency penalties verification applies are not applied to
the proposal, and acceptance falls as the penalized history grows. Speculation
stays off entirely for a turn carrying an image or audio attachment, whose
embeddings only the plain prefill can inject.
The same drafter serves the HTTP API. Pass it with --draft-model and turn
speculation on with --mtp-spec:
TensorSharp.Server --model DeepSeek-V4-Flash-...-00001-of-00005.gguf \
--backend ggml_cuda --tp 4 \
--mtp-spec --draft-model DSpark-drafter-Q2K-Q8-0731.ggufUnlike the CLI, the engine draws every verify row with the request's own
sampler, so speculation composes with any sampling settings and the output is
whatever that sampler would have produced anyway. Penalties only cost
acceptance, and measurably little: the same prompt at repeat_penalty 1.1 vs
1.0 came out at 31.3 vs 32.1 tok/s (66% vs 57% acceptance — the difference is
which text was generated, not the penalty).
Two engine-level limits are worth knowing. Speculation is armed per request at
a fresh full prefill, and it serves solo sequences only: as soon as a second
request is in flight the planner logs
PerSequenceFused; rejected: SpecPerSequence: multi-sequence step and DSV4's
per-sequence slots serve the batch at normal decode speed. Concurrency is safe,
it just isn't speculative.
Measured on 4×A40 (--tp 4, 300-token OpenAI chat completion):
| Config | tok/s |
|---|---|
| No drafter | 25.1 |
--mtp-spec --draft-model … |
31.3 – 32.1 (1.25–1.28x) |
--mtp-pmin defaults to the value matching the loaded drafter — 0.35 for a
block drafter, 0.75 for a per-token draft head — so it needs no tuning. Setting
it explicitly still wins; the startup line reports which gate is in force
(pMin=0.35, draft=block(5)).
The two engines implement the same algorithm differently. The ggml engine
builds the drafter as three extra layers of the model graph: the trunk graph
captures the target features, projects them and commits the drafter's key ring
in the same pass, and the draft itself is one cached graph whose Markov chain
(get_rows -> mul_mat -> argmax, per block position) runs entirely
on-device. The direct-CUDA engine drives the same stages with its own
kernels and feeds the drafter its target features through the host. Nothing in
the C# speculative core is backend-specific.
The drafter is NOT in the target GGUF: every GGUF conversion of DeepSeek V4 drops
the mtp.* tensors (DeepSeek-V4-Flash-0731-UD-Q8_K_XL has blk.0-blk.42 and
nothing else). Either download a pre-built drafter GGUF (three are listed in
Model Downloads; publishers spell the
tensors and metadata differently and the loader accepts each spelling), or build
one from the upstream safetensors checkpoint.
Any DeepSeek V4 release whose config.json has dspark_block_size carries the
module: deepseek-ai/DeepSeek-V4-Flash-0731, deepseek-ai/DeepSeek-V4-Flash-DSpark,
deepseek-ai/DeepSeek-V4-Pro-DSpark. Only the shards holding mtp.* are needed
(the last three, ~11 GB of the ~340 GB repo):
REPO=deepseek-ai/DeepSeek-V4-Flash-0731
B=https://huggingface.co/$REPO/resolve/main
mkdir -p dspark-src && cd dspark-src
curl -sLO $B/config.json
curl -sLO $B/model.safetensors.index.json
# the shards holding mtp.* (model-000{46,47,48}-of-00048 for the 0731 release)
python - <<'PY' | while read f; do curl -sLO $B/$f; done
import json
wm = json.load(open("model.safetensors.index.json"))["weight_map"]
print("\n".join(sorted({v for k, v in wm.items() if k.startswith("mtp.")})))
PYThen convert (the tokenizer and the other 45 shards are never opened):
python eng/dsv4-dspark-to-gguf.py --checkpoint dspark-src \
--out DeepSeek-V4-Flash-0731-DSpark.gguf --expert-type q2_kThe routed experts are stored as FP4 with per-32 E8M0 scales in the checkpoint,
which is GGUF's MXFP4 layout up to nibble order, so mxfp4 repacks them
losslessly (~11 GB); --expert-type q2_k roughly halves that.
Drafter size is a real trade-off. Its weights are re-read on every speculative step, so a bigger drafter has to earn its bandwidth back in acceptance. On 4xA40 the effect was clear on the direct-CUDA engine (5.6 GB 2-bit: 34.0 tok/s / 69% accepted, vs 10.9 GB MXFP4: 30.9 / 65%) and inside run-to-run noise on ggml (31.1 / 31.5 / 31.8 tok/s for the 5.6 GB, 7 GB and 10.9 GB builds on a 120-token sample, acceptance rising 63% -> 66% -> 68% with size). Start with a small one; move up only if acceptance is your bottleneck.
| Flag | Default | Meaning |
|---|---|---|
--draft-model <path> |
none | DSpark drafter GGUF (env TS_DSV4_DSPARK) |
--spec-draft-n-max <N> |
block size (5) | Cap on tokens drafted per step |
--spec-draft-conf-min <p> |
0.35 |
Minimum CUMULATIVE acceptance probability (the product of the confidence head's per-position estimates) for a drafted position to be kept |
--spec-draft-conf-min is the knob that matters: an extra verify row costs
roughly a quarter of a decode step on this sparse-MoE trunk (each row pulls in
its own set of experts), so drafting past a ~0.35 prefix-acceptance estimate is
expected-negative. Lower values draft more and roll back more; higher values
fall back to plain decode more often.
The drafter needs its three target feature layers on the device that owns the
output head; the layer split reserves room for it automatically, and loading
fails with a clear message if the split cannot satisfy that (reduce the GPU
count or drop --draft-model). The split balances the LARGEST per-device load,
counting each device's fixed residents — the embedding table on the first
device, the output head and the whole drafter on the last — where they actually
land. Spreading those over every device instead (what a plain byte-proportional
split does) left the first device ~1.7 GB of surplus weights with a drafter
loaded, which was enough for a long prompt's prefill compute buffer to no
longer fit in VRAM.
Measured on 4xA40 46 GB (DeepSeek-V4-Flash-0731 UD-Q8_K_XL, greedy, 200-token generation, 5.6 GB drafter):
| Metric | cuda baseline |
cuda + DSpark |
ggml_cuda baseline |
ggml_cuda + DSpark |
|---|---|---|---|---|
| Decode | 26.0 tok/s | 34.0 tok/s (1.31x) | 26.4 tok/s | 37.1 tok/s (1.41x) |
| Prefill (15K prompt) | 962 tok/s | 955 tok/s | 952 tok/s | 954 tok/s |
| Acceptance | — | 69% | — | 69% |
Multi-turn decode benefits most (2.2x on the third turn of the sample conversation, 93% acceptance): a turn that continues an established context is exactly where the drafter is confident.
Same box, --interactive --think --tp 4 with the 7 GB Q2K-Q8 0731 drafter, a
5-turn chat: short answer, long explanation, follow-up summary, then a 10K-token
document with two questions about it.
| Turn | Baseline | + DSpark | Acceptance |
|---|---|---|---|
| 1 short (53 tokens) | 25.6 tok/s | 44.4 tok/s (1.73x) | 87% |
| 2 long generation (512) | 26.4 tok/s | 39.6 tok/s (1.50x) | 66% |
| 3 follow-up (470) | 26.4 tok/s | 45.3 tok/s (1.72x) | 76% |
| 4 10K-token document (214) | 25.3 tok/s | 51.0 tok/s (2.02x) | 85% |
| 5 second question on it (156) | 25.4 tok/s | 49.3 tok/s (1.94x) | 82% |
Prefill stays at parity (831 vs 835 tok/s on the 10K prompt). Acceptance — and so the speedup — is highest where the next tokens are most predictable: a question answered out of a document in context beats free-form prose.
Greedy output was byte-identical to the non-speculative baseline on both the 200-token generation and the 15K-context run. Speculation only re-orders the arithmetic of the accepted tokens, so a long enough run can still diverge from sequential decode wherever a batched verify row lands on a near-tie — the same batched-vs-sequential drift that separates prefill from decode.
Why 1.3x and not more: each extra token in the verify batch pulls its own set of routed experts through VRAM (the trunk is 6-of-256 sparse), so a verify row costs ~a quarter of a full decode step no matter how cheap the draft was. The confidence gate is what keeps that trade positive.
| Env | Default | Meaning |
|---|---|---|
MAX_CONTEXT |
65536 | Context window (caches scale with it; metadata allows 1M) |
TS_DSV4_UBATCH |
512 CPU / 1024 GPU | Prefill micro-batch |
TS_DSV4_NGPU |
all | Number of GPUs to layer-split across (GPU backends) |
TS_DSV4_VRAM_RESERVE_MB |
2048 | GPU backends: VRAM held back per device for the scheduler's compute buffers. Lower it to offload fewer expert layers; raise it if a long prompt fails to allocate its graph |
TS_N_CPU_MOE / TS_CPU_MOE |
0 (off) | Leading layers whose routed experts stay in system RAM (same as --n-cpu-moe / --cpu-moe). Off by default; a model that does not fit is refused with the number that would work |
TS_CPU_MOE_THREADS |
half the usable CPUs | Worker threads for the host expert matmul. hardware_concurrency clamped by the affinity mask and the cgroup CPU quota, then halved on hosts with more than 8 — the accelerator submission threads (and, when hosted, Kestrel and the scheduler) have to be schedulable too. Sizing this near the quota collapses rather than degrades: 96 threads on a 23.8-CPU quota measured 25x slower than 23, and on a 95-CPU quota a hosted MoE ran 8.2 tok/s at 71 threads against 20.7 at 64 |
TS_DSV4_LOAD_THREADS |
16 | --backend cuda: reader threads for the stream-to-VRAM loader |
TS_DSV4_LOAD_STATS |
0 | --backend cuda: 1 = per-stage loader timings |
TS_DSV4_STAGED_EXPERTS |
1 | --backend cuda: 0 = per-token expert kernels (A/B) |
TS_CUDA_BF16_MATVEC |
1 | 0 = single-row BF16 projections via cuBLAS instead of the dedicated matvec (TS_DSV4_BF16_MATVEC also accepted) |
TS_DSV4_FA |
1 | Flash attention (auto-probed, GPU backends) |
TS_DSV4_PERF |
0 | 1 = tok/s log + DSpark draft phase timings, 2 = per-ubatch stage timing |
TS_DSV4_DSPARK |
— | DSpark drafter GGUF path (same as --draft-model) |
TS_DSV4_DSPARK_CAPTURE |
1 | 0 = skip the drafter's target-feature capture (A/B knob; drafts then go stale and are rejected) |
TS_DSV4_THREADS |
all cores | CPU executor worker threads |
TS_DSV4_MMAP |
1 | CPU executor: 0 = copy all weights into RAM at load (parallel reads; use when the model sits on a network filesystem) |
TS_DSV4_BUFFER_SHARDS |
— | CPU executor: comma-separated 1-based shard indexes to copy into RAM (mmap the rest) |
TS_DSV4_MLOCK |
1 | CPU executor: best-effort mlock of mapped shards (needs a memlock rlimit) |
TS_DSV4_SPINPOOL |
0 | CPU executor: 1 = persistent spinning worker pool (helps on dedicated boxes, hurts under shared CPU quotas) |
| Metric | TensorSharp | llama.cpp (same box) |
|---|---|---|
| Prefill (3.3K prompt) | ~500 tok/s | 574 (pp512) / 634 (pp4096) |
| Decode @3.3K ctx | ~33 tok/s | 40.3 (tg128) |
Long-context recall (needle tests at 4.6K and 15K tokens) retrieves planted facts exactly — the compressed-attention paths are exercised beyond the raw 128-token window.