Upstream merged DSpark speculative decoding on 2026-07-01 (#46995,
follow-up #47093 speculators/reduced-vocab). Ours predates it by 3 days
(279de1956f, Luke, 2026-06-28 + TP4 TileLang follow-ups). They are parallel implementations of the same
algorithm — disjoint git histories — that even share file paths (vllm/models/deepseek_v4/nvidia/dspark.py,
spec_decode/dspark/speculator.py). This page: E2E + acceptance measurements, code-level comparison,
and the consolidation/port plan.
| ours (eldritch 3f65c52 + b12x f416b75) | upstream nightly (post-#46995) | |
|---|---|---|
| decode cc1 (512 tok) | 188.1 tok/s | does not start |
| coding gen-only (2k tok) | 278.0 tok/s | — |
| acceptance / draft token | 36.2 % (5,773/15,940) | — (see §2) |
| accepted per draft (+bonus) | 1.81 (+1 ⇒ ~2.81 tok/step) | — |
| per-position acceptance (of 3,188 drafts) | 73.9 / 48.7 / 30.4 / 17.9 / 10.3 % | — |
Ours config: seqs 64 / graph 512 / mbt 8192 / 128k len / A8 / {"method":"dspark","num_speculative_tokens":5,"draft_sample_method":"probabilistic"}.
Consistent with the 2026-07-01 baseline (cc1 191.3 on b12x2387, prod cache/len deltas).
Peeled one at a time on vllm/vllm-openai:nightly (5 boot cycles):
- DSpark crash at speculator init:
AttributeError: 'DSparkDeepseekV4ForCausalLM' object has no attribute 'draft_id_to_target_id'— their #47093 reduced-vocab refactor broke the DSV4 draft; fix sits in open PR #47429 (we applied it manually to proceed). - Bundled DeepGEMM has no SM120 support:
Assertion error (deepgemm-src/csrc/apis/layout.hpp:59): Unknown SF transformationduring FP8 weight processing. (Our stack ships an SM120-patched DeepGEMM fork — this is precisely the delta.) VLLM_USE_DEEP_GEMM=0fallback → CUTLASS c3xdispatch_scaled_mmRuntimeError — upstream has no SM120 fp8-blockwise scaled-mm kernels either;VLLM_TEST_FORCE_FP8_MARLIN=1does not reroute the blockwise path.- tilelang wheel broken in the image:
libcudart_stub.so: undefined symbol: cudaDeviceReset(the DSV4 target needs TileLang for the HC head) — worked around by mounting the real libcudart over the stub.
Conclusion: pure upstream vLLM cannot serve DeepSeek-V4-Flash (with or without DSpark) on SM120 today. Its DSpark speed on this hardware is therefore N/A; every layer that makes DS4 runnable here (SM120 DeepGEMM, b12x kernels, working TileLang) lives in our stack. A fair speed comparison would need SM90/SM100 hardware.
E2E acceptance for upstream is unmeasurable on this box, but the code answer is solid:
- Identical rejection scheme by default — both trees share the same
_rejection_kernel(greedy: accept iff draft == target argmax; temp>0: Leviathan ratio test with the same seeding granularity). Atnum_speculative_tokens == dspark_block_sizethe draft layouts are token-for-token identical ⇒ same expected acceptance on the same checkpoint. - Upstream likely LOSES acceptance when
N < dspark_block_size: they truncate the query block to N (shape never seen in training); we always run the full trained block and slice (costs a little compute, keeps fidelity). - Upstream GAINS acceptance at temp>0 via
rejection_sample_method="block"(block verification, #46781 + int64 fix #47383) — joint block acceptance with residual-mass reweighting. We lack this entirely — the single biggest acceptance feature gap on our side. - Gumbel keying differs by one position offset (both self-consistent; second-order).
- Our batch-wide draft suppression (prefix-cache window rebuild ⇒ 0-width drafts for the whole batch;
len ≤ 1⇒ zero-token drafts for the whole batch) depresses effective tokens/step in mixed batches; upstream proposes per-request unconditionally.
Draft model. Ours (1,994 lines): self-contained, private per-request dense rolling KV window
(no paged cache, layers popped from the forward context), bespoke TileLang sparse block-attention
kernel (E2E-tuned: TP4-native h=16/block=32/threads=128 → 376.6 tok/s, beating the isolated-bench
winner by 7% — another isolated-vs-E2E case), b12x fused WO/MHC, fake-FP8 QAT numerics matching
training, confidence head, env-gated reference/debug harness. Upstream (488 lines): thin wrapper reusing
the target's stack — stock decoder layers, paged SWA draft KV (sparse_swa.py, fp8_ds_mla layout),
fused RoPE/quant/paged-insert CUDA ops, FlashMLA/FlashInfer sparse kernels (SM120 path requires fp8 KV),
torch.compile on the draft.
Speculator. Upstream rewrote DSpark as a subclass of their DFlash parallel-drafting framework:
one fused Triton prep kernel writes all graph inputs (ids, positions, both slot mappings, padding) in
place; per-request proposals; pad_spec_decode keeps uniform decode graphs when requests join;
reduced-vocab d2t; EPLB hooks. Ours: standalone speculator with a per-step Python state machine for the
private window (_update_context_cache_state), FlashInfer MoE autotune pinned to the draft block bucket,
numerics hardening (fp64 acceptance uniforms, gumbel eps clamps), vectorized flatten kernel.
Verdict: upstream's integration architecture is better (paged draft KV, fused prep, per-request robustness, ~10× less DSpark-specific code); our kernels and DSV4 fidelity are better (TileLang draft attention with fewer indirections wins single-stream; b12x fusions; full trained block; QAT-exact numerics). On this hardware ours is faster by construction — upstream's stack doesn't even run.
Upstream → ours (priority order):
- Block verification (
rejection_sample_method="block"kernels + config, incl. #47383 int64 casts) — direct accepted-length win at temp>0, orthogonal to our drafter. - Scheduler
pad_spec_decode— uniform-decode FULL graphs survive requests joining a spec batch. - Base-class
idx_mapping[num_reqs:].fill_(-1)defense-in-depth (our active-row guard covers DSpark only; audit eagle/mtp/autoregressive for stale-row corruption). - Replace batch-wide suppression with per-request
-1draft fill (and stop proposing token id 0 forlen ≤ 1anchors). - Skip the dead confidence-head compute in
forward_head. - Longer term: paged draft-KV/SWA-group design (prefix caching + preemption without the trusted-window state machine).
Ours → upstream (if Luke wants to upstream):
- TileLang draft attention as the SM120 path (their SM120 FlashInfer sparse path needs cubins that may
not ship); gumbel eps clamps + finite guard; fp64 acceptance uniforms; vectorized
_flatten_sampled; FlashInfer MoE autotune bucket for block-M;requires_eagle_cache_droprefinement; "run full trained block when N < block_size" (fidelity); the #47429 one-liner is already ours-equivalent.
Kernel-level headroom on ours (draft pass):
- Kill the two per-layer window copies (
cache_windowgather +torch.cat) — kernel reads split (cache, draft) tensors, or allocate window+block contiguously and write the block into the tail. - Hoist
_build_dspark_topk_idxsout of the per-layer loop (layer-invariant; or fold the analytic index into the kernel — no index tensor at all). - m-fusion: one CTA per request processing all 5 draft queries (5× fewer KV re-reads; also solves the TP8 h=8 padding waste).
- Do NOT: fp8 window in-kernel (latency-bound, not BW), PDL/relaxed atomics (−7 % E2E, measured), block=64/FullCol (lost E2E by 7 %, measured).
- Ours:
tests/v1/spec_decode/test_dspark.pyimports_get_tilelang_block_size/_get_tilelang_padded_headsdeleted by29516ba9af— the test file fails at import; needs updating. - Upstream: the #47429 crash (still open); nightly tilelang wheel broken stub; no SM120 story for DS4 across three kernel layers.
Ported upstream's block verification into our tree (branch
fable/dspark-block-verification-20260703 @ local-inference-lab/vllm): wholesale adoption of their
refactored rejection_sampler_utils.py (block kernels, int64-safe offsets) with our extras re-applied
(fp64 acceptance uniforms, active-row-guarded gumbel), rejection_sample_method: "block" config,
and a host-side all-greedy skip (the block prep kernels cost ~5–7 % of a step and are inert at temp 0
— without the skip, greedy decode lost 7 % tok/s).
Testing strategy that made this fast (2 boots total instead of ~10):
- Synthetic kernel harness (
optimization/code/tiny-decode/synth_rejection_test.py): drivesrejection_sample()directly with controlled target/draft divergence. Seconds per iteration. Results: temp-0 equivalence exact; distribution preservation (TVD within sampling bounds); accepted length +7–47 % over standard across temp × draft-quality on Gaussian-noise drafts. - E2E with proper statistics: 12×800-token probes ×3 per config at temp 0.7 (small probes have ±9-point noise — trajectories are not run-to-run deterministic on this stack due to fp32-atomic logit jitter; and first-run-after-boot reads low, again).
E2E verdict: no real-workload gain. block 54.0/56.4/54.2 % vs standard 54.1/56.0/59.3 % (means 54.8 vs 56.5, spread ±3) — the DSpark draft's error structure is bimodal (agree-hard / diverge-hard), not the smooth likelihood-ratio spectrum where joint-ratio pooling wins. The +5-point gain seen in first small probes was sampling noise. Kept opt-in, not default. Value delivered anyway: consolidated rejection utils (upstream refactor + our hardening in one file), the synthetic harness as a permanent regression tool, and the measured-noise methodology (big paired probes or bust) for all future acceptance work.
Next speed items from §4 remain open: draft-pass window-copy elimination, topk hoist, confidence-head skip, per-request suppression fix, pad_spec_decode.
Profiled the step precisely first (CUDA-event instrumentation inside the graph-replay hot path — torch profiler cannot see inside FULL cudagraphs): step ≈ 13.6–14.9 ms = target verify M=6 ~11.3 ms (83 %) + draft graph 2.01 ms + prep 0.33 ms. The verify pass is near its weight-streaming floor (~33 distinct experts × 6.3 MB × 61 layers), which reframed the campaign:
| lever | result |
|---|---|
| k-sweep (5→3→2) | falsified: 251 / 218 / 192 tok/s greedy — verify scales weakly with M, shorter blocks just lose tokens |
| A16 MoE for DSpark | tie (244.5 vs 241.3) — the 0701 +9 % edge no longer reproduces on the f416b75 stack |
| draft cleanup (confidence-head skip, per-step topk sharing, persistent window+block staging) | landed: draft graph-forward 2.01 → 1.81 ms/step (−10 % draft, ~+1.4 % E2E), coherence clean; commit 16140bcb |
The timing instrumentation (VLLM_DSPARK_TIMING=1) stays as the permanent per-change A/B tool.
Remaining ideas are structural (fp8 draft window, cross-step expert dedup) with uncertain payoff.
Paired fresh boots, warmed, cc64 measured twice per side (first-run cc64 is unreliable — FI autotune cold start measured 572→1927 on the hybrid; bench order matters too: cc64 after a 128k prefill sweep reads −15–20 %):
| DSpark TP2 A8, k=5 | full B12X (f416b75) | Lucifer CUTLASS | winner |
|---|---|---|---|
| decode cc1 | 197.3 | 202.2 | ~tie (+2.5 % Lucifer) |
| decode cc64 aggregate | 1,925 (30.1/user) | 2,331 (36.4/user) | Lucifer +21 % |
| prefill 8k / 64k / 128k | 12,972 / 12,875 / 12,067 | 12,201 / 12,194 / 11,314 | B12X +6 % |
Hybrid (B12X attention + FI MoE) ≈ B12X at cc64 (1927) → the cc64 gap isn't MoE alone; Lucifer's attention path contributes at batch. Serving guidance: throughput deployments → Lucifer config; prefill/TTFT-heavy → full B12X. Kernel work item for the b12x stream: the dynamic w4a8 MoE band at M≈64–384 (spec-decode verify batches) trails FI CUTLASS MXFP4×MXFP8 by enough to cost −21 % E2E at cc64.