DeepSeek V4 DSpark Internals¶
This page describes the DSpark runner and kernel contracts. For checkpoint preparation, supported flags, and an HTTP smoke test, see DSpark Deployment.
Implementation Entry Points¶
| Layer | Interface and responsibility |
|---|---|
| CLI | cli/main.py selects PyptoDeepSeekV4DSparkExecutor in _executor_cls_for_model_family, validates TP4/EP topology in _validate_dspark_topology, and builds seven cache groups in _build_runtime_config. |
| Executor | model/deepseek_dspark/npu_executor.py: DeepSeekV4DSparkPyptoExecutor._compile_model() imports and compiles the DSpark prefill/decode programs; _create_runner() constructs the runner. |
| Runner | model/deepseek_dspark/npu_runner.py: DSparkModelRunner.init_kv_cache() allocates post-weight pools; prepare_prefill_inputs() and prepare_decode_inputs() lower scheduler batches into the frozen kernel ABI. |
| Worker | serving/server/serving_worker.py: _make_prefill_batch() and _make_decode_batch() pass group block IDs and cache partitions; _batch_prefill() calls finalize_prefill() for terminal chunks. |
| Weights | model/deepseek_dspark/weight_loader.py: DSparkWeightStore packs target slabs and, for K=7, the three-layer drafter bank. |
The CLI accepts two or four TP4 groups (--ep 8 or 16, with --dp 2 or 4). The 16-rank deployment discussed below has four scheduler cache partitions. Physical block IDs are local to each partition and replicated across its four TP ranks.
Prefix caching¶
The HCA cache layout changes the prefill/decode kernel ABI. On the first
launch after upgrading, set PYPTO_CACHE=0 (or unset it) to regenerate both
graphs.
The seven target cache families use the shared grouped prefix-cache manager.
Lookup selects the TP partition with the longest reusable prefix, sharing
full compressed-history pages and the immutable rolling tail. The allocator
detaches rolling write destinations before continuing a shared prefix.
build_dspark_cache_group_specs() defines these groups; DSparkModelRunner.init_kv_cache()
allocates their replicated device pools after weights are materialized. The scheduler
hands each batch block_ids_by_group and cache_partitions, which
DSparkCacheMetadataBuilder lowers into the tables and slot mappings used by the kernels.
Rolling pool capacities include both the configured prefill chunk and the
historical window, because prefill writes its whole chunk before attention
reads the prefix. The pool budget follows the active chunk limit; the CSA
lookup requires only its eight-token historical tail. The fused decode state
descriptors use the same physical ring lengths as the scheduler, so device-side
modulo stays aligned after wraparound, including after a prefix-cache hit.
The drafter's private caches are rebuilt rather than shared. K=7 sets
speculative_prefix_cache_replay_tokens=128 in the runtime contract; the
scheduler caps the hit before rounding down to the grouped page alignment.
Suffix prefill may span several chunks. Its last 128 hidden rows and absolute
positions seed a fresh drafter lease through the normal prefill-finalization
path. The runner rejects an incomplete tail instead of reading stale private
KV from a previous lease. A prefix leaving fewer than 128 suffix tokens falls
back to an earlier aligned hit or a cold prefill.
The checkpoint's max_position_embeddings must cover the requested Serving limit.
How the deployment maps onto the kernels¶
DeepSeekV4DSparkPyptoExecutor._compile_model() loads the PyPTO programs from
pypto-lib/models/deepseek_v4_flash_dspark. DSparkModelRunner.prepare_prefill_inputs()
and prepare_decode_inputs() translate PrefillBatch and DecodeBatch into
kernel metadata; run_prefill() and run_decode() dispatch them. The worker
calls finalize_prefill() for terminal chunks so a K=7 request has drafter
state before decode. These interfaces are the handoff points to inspect when
changing the kernel ABI.
- Cache partitions = TP groups. The scheduler sees 4 partitions. Every rank of a group holds an identical replicated pool: prefill writes the same rows on all four ranks, and decode rebuilds the group's whole KV stream from the gathered token rows each step (pypto-lib#1079).
- Long-context capacity is logical, not fully resident. Ratio-128 history grows to 8192 single-row pages per request at 1M; ratio-4 and index history also grow to 8192 pages. Raw KV and HCA/CSA state remain rolling physical pools. Startup memory admission still needs room for one complete logical-capacity request plus scratch pages.
- Block tables are staged at the kernels' frozen depths (CSA compressed
and indexer tables 8192 entries, HCA compress-state table 131072, CSA
compress-state tables 524288, -1 past
each request's span). The generated orchestration reshapes these tables
with the frozen depths baked in, so a shallower table asserts on device and
surfaces as an opaque AICore 507901; the depths therefore track the kernel
constants (
IDX_MAX_BLOCKS,CMP_MAX_BLOCKS,COMPRESS_STATE_MAX_BLOCKS), never the serving context ceiling. - Prefill can pack multiple requests per TP group, within 64 requests and
8192 total chunk tokens per group (256 requests per dispatch across four
groups). These
are admission capacities, not a claim that every occupancy is hardware
validated. The worker splits dispatches at either limit; configured
--max-num-seqs, token budgets, and available cache can impose lower limits. Request boundaries usequery_start_loc; each request has separate block tables, absolute positions, and a last-token output row on the group leader. The physical token extent is the largest packed group length rounded up to TP4 alignment, not the per-request context limit. Padding has zero inputs, synthetic positions, and-1cache mappings. Request metadata backing capacity follows--max-num-seqswithin the kernel limits: the global request capacity ismin(max_num_seqs, 256)and each group's metadata capacity ismin(max_num_seqs, 64). The per-group capacity is not divided by four, so uneven groups can share a dispatch. Worker admission follows these compiled capacities; dispatch descriptors bind only the live request-axis extent, with repeated terminal boundaries for groups containing fewer requests. Packed prefill is enabled automatically; no environment toggle is needed. Actual dispatch sizes follow scheduled requests and per-group token budgets, without waiting for a full batch. A 32-request offline run with ragged 33-512 token prompts, 128-token chunked prefill, and K=7 speculation completed end-to-end with correct token accounting at eight requests per group. Intermittent EP16 prefill stalls reproduce without packed prefill (single-request controls and the kernel-side golden fixture fail the same way), so they are not attributed to request packing; they are tracked in pypto-lib#1213. - Decode always runs the full 512-row group tile (16 requests x 8 rows per rank). Row 0 of each request carries the committed token and is the only accepted row in this milestone (one token per step); rows 1-7 carry the DSpark noise token and their compressed-boundary writes are masked so uncommitted rows never publish cache entries.
- Weights load through the standard lazy store (no prepack sidecar). The decode weight bank stacks all layers with the HC function matrices padded to 32 storage rows; the unpadded prefill slabs are derived from the same data. The output projection is TP-sharded (2 of 8 groups per rank) and regathered on device.
Constrained Decode ABI¶
serving/constraints/provider.py owns the request-local XGrammar state. The worker
passes it with terminal prefill and decode batches; DSparkModelRunner._stage_prefill_grammar()
and _stage_decode_grammar() copy allowed-token masks into the buffers defined in
model/deepseek_dspark/task_args.py. The prefill and decode positional ABIs both
include grammar_mask; fused K7 decode also includes valid_draft_counts.
Serving and PyPTO-Lib must therefore be upgraded together.
Ordinary requests reuse immutable all-allowed masks and default draft counts.
The mask's row-mode header occupies segment 0's last padding word: -1 selects
ordinary argmax, while 0 enables masking. The header is packed by Serving,
not supplied by XGrammar. Constrained requests reuse request-local host bitmask
storage. Draft validation and mask generation share one speculative traversal,
then roll back; only committed output advances the grammar. With asynchronous
scheduling, acceptance-independent metadata may prepare early, but mask
planning waits for that request's prior output to commit. Mutable slot ownership
lasts through output reclaim, and masks still use the full-tensor runtime upload
path rather than partial host-to-device updates.
Ring heaps¶
Split target decode uses (1, 1, 1, 4) GiB at the four scope depths for the frozen
1M layout. Its deepest scope retains partial-attention output, normalization,
and stream tensors; a 1 GiB heap is insufficient. Markov uses a separate 1 GiB
profile, and the drafter uses (4, 4, 4, 4) GiB. Fused K=7 decode includes
the drafter and retains the upstream (4, 4, 4, 4) GiB profile.
PYPTO_DSPARK_DECODE_RING_HEAP overrides only split target decode. It accepts one
byte count (broadcast to all four scope depths) or four comma-separated byte counts.
PyPTO RunConfig validates the sizes: nonzero entries must be powers of two
and at least 1024; zero leaves that scope at its runtime default. To retain the default profile, use
1073741824,1073741824,1073741824,4294967296. A scalar 4294967296 instead
allocates 4 GiB at every depth. The legacy scalar 1073741824 leaves the deepest
scope at 1 GiB. Smaller overrides require separate device validation; they do
not change the table ABI.
The default DSpark ring heap is prefill's rebalanced profile,
(2, 2, 4, 8) GiB per scope depth (pypto-lib#1073); the example command pins
it explicitly. PTO2_RING_* environment variables are dead -- sizing flows
through the per-dispatch RunConfig only (pypto-lib#1075).
Weight-bank caveat¶
The 16-card kernel-harness runs validated prefill_fwd.py and decode_fwd.py
at this topology; the decode witness ran with a one-layer reusable weight
bank, so serving's --weight-bank-size 43 (all-layer resident banks) was
first exercised end-to-end by this integration.
Accuracy checks¶
PYPTO_DSV4_DSPARK_MODEL_DIR=/data/models/dsv4-flash-0731-dspark-w8a8 \
TASK_DEVICE=0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15 \
python -m pytest tests/test_deepseek_dspark_accuracy.py -q
tests/test_deepseek_dspark_accuracy.py starts the HTTP server on 16 borrowed
devices, then checks greedy generation with two short cases mirroring the MTP guard's
64-token prompt / 128-token gate (the same Palace Museum prompt, so both
variants gate the same request shape): the target-only K=0 contract and the
K=7 speculative chain. Two additional prefix-cache cases compare cold and
cached completions at K=0 and K=7 and require an observable 128-token cache
hit. Run these alone with -k prefix-cache.
Greedy is the only sampling mode -- the kernels expose
device greedy sampling with no temperature ABI, so requests with
temperature > 0 fail with an explicit error.
Unit guards (no devices needed): tests/unit/model/deepseek_dspark/ covers
the cache topology contract, the ABI order parity against the pypto-lib
signatures (prefill/decode/drafter/markov), the import-context isolation, host
metadata lowering parity against the pypto-lib reference helpers,
prefill/decode staging assembly, the drafter lease and staging contracts,
linear-chain acceptance semantics, and the weight shard policy (synthetic
checkpoints; the production checkpoint validates opt-in through
PYPTO_DSV4_DSPARK_MODEL_DIR).
Speculative decoding (K=7)¶
Select with --speculative-config '{"method": "dspark",
"num_speculative_tokens": 7}'; 0 keeps the target-only path. K is fixed at
DSPARK_QUERY_WIDTH = 7 -- the decode tile is exactly one committed row plus
seven draft rows.
The runner owns the request-to-lease map in _drafter_states.
_reserve_drafter_state() and release_finished_requests() manage its lifetime;
_seed_prefill_wave() establishes the initial context after terminal prefill.
For decode, _prepare_decode_plan() and _stage_device_prepared_decode() build
the target inputs; _accept_decode_outputs_on_device() and
_accept_decode_outputs_on_host() apply the accepted chain. Keep the scheduler's
committed lengths as the source of truth when changing any of these steps.
Programs. l3_dspark_drafter (59 args at the pinned pypto-lib) and
l3_distributed_markov_sample (12 args) compile alongside the target
programs only when num_speculative_tokens > 0; the K=0 path loads, requires,
allocates, and compiles nothing drafter-related. The drafter dispatches under
its own (4 GiB,)*4 ring profile, markov under the 1 GiB decode profile.
Weights. DSparkWeightStore.load_drafter_weights packs the checkpoint's
mtp.0/1/2 modules plus three replicated heads (mtp.0.main_proj/main_norm,
mtp.2.norm, mtp.2.markov_head.markov_w1/w2, mtp.2.confidence_head) and
the target hash layers' tid2eid (INT32) into the kernel's flattened
three-layer banks: the checkpoint's [out, in] projections transpose
(wq_a/wq_b/wkv), the router gate and confidence head cast to FP32, the
o-projection TP-shards (wo_a groups / wo_b columns), and the routed
experts EP-shard per rank. All banks upload inside _ensure_l3_shared_buffers,
so the KV-capacity free-memory snapshot sees them.
Drafter caches. The drafter's SWA pools are runner-private
([16, 3, 512, 32, 1, 512] BF16, one full group replica per rank) and never
scheduler-visible. Each live request holds a stable group-local lease in
[0, 64): six ring blocks per draft layer
(lease*6 + (logical + 7*layer) % 6), sized so a 128-deep window plus the
seven query rows never aliases itself at any alignment. Blocks 384-389 hold a
shared read-only zero-initialized filler history; filler batch rows publish
nothing (-1 slots, token 0, num_sampled = 0). Leases free on completion,
abort, and preemption; a re-admitted re-prefill starts a fresh incarnation.
Packed-prefill seeding. Backbone hidden rows are extracted by each
request's packed interval, including intervals crossing TP rank bands. Each
request retains its own last 128 prompt rows. The drafter seed ABI holds one
context/lease per group, so terminal-prefill requests seed in waves containing
at most one request per group; a later request cannot overwrite another
request's seed context. This does not fix the known inactive-group decode
TENSOR_WAIT_TIMEOUT seen when an active batch shrinks from four to three.
Verify staging (eager publish). Speculative decode stages the pending
drafts into verify rows 1..7 and publishes all eight rows of the window (raw
KV, recurrent states, and compression boundaries; kv_seq_lens = start + 8).
Rejection self-heals without rollback: every stale position a truncated
acceptance leaves behind falls inside the next dispatch's eight-row window
and is rewritten with correct tokens before any read, because reads stay
bounded by the committed lengths. When the window cannot fit under the
position ceiling the request falls back per-step to the single-anchor K=0
publication path (no clamped duplicate positions ever carry real slots), and
its next drafter query is skipped. Terminal truncation (EOS, stop, max
tokens) remains scheduler-authoritative, including a fully accepted step
ending mid-list.
Acceptance. The device greedy sampler emits one prediction per logit row;
the host runs linear-chain acceptance (longest matching draft prefix plus the
target's own prediction at the stop, 1..8 tokens per step) and returns
variable-length accepted_token_ids, which the scheduler already consumes.
Acceptance changes speed, never the served contract: the e2e guard asserts
token accounting only, plus the runner's DSpark speculation progress log
line as proof the chain really dispatched. Per-request counters (verify
steps, proposed/matched drafts, accepted tokens, fallback steps, mean
accepted length before scheduler truncation) are exposed through
DSparkModelRunner.dspark_speculation_summary(); mean accepted length is a
bring-up effectiveness goal, not a coherence or latency claim.
Seeding. run_prefill captures a rolling 128-row prompt tail from the
prefill tap's rank-owned rows on every chunk; the worker's terminal-prefill
hook (finalize_prefill, which knows prompt completion from its cached
length) validates the completed subset and dispatches the drafter in prefill
mode (num_sampled = 0, the first sampled token as next_prefill_tokens,
anchor at the prompt end) followed by markov, before the first decode step.