TL;DR: DSpark drafts a block of γ tokens in one parallel pass, then fixes it up with a small serial walk. Every step of that walk multiplies by a matrix as wide as the vocabulary. We run the whole walk as one persistent kernel that keeps the matrix on chip.

Code: github.com/tie-pilot-qxw/dspark-markov-walk · SGLang PR: sgl-project/sglang#42855


The serial part of a parallel drafter

A block drafter such as DSpark guesses a whole block of tokens in one forward pass. That pass is parallel, so it is cheap. The catch is that every position is guessed blind: position 3 does not know what positions 1 and 2 turned out to be.

DSpark wins part of that back with a small Markov head. After the parallel pass, it walks the block from left to right. At each step it adds a bias that depends on the token it just picked:

prev = anchor
for k in range(γ):
    u       = W1[prev]                 # [256]
    logit_k = bf16(base_k + W2 @ u)    # [V]
    tok_k   = argmax(logit_k)          # or a sample of softmax(logit_k / T)
    prev    = tok_k

The sketch omits one detail: SGLang’s default walk also rounds W2 @ u to bf16 before the add.

Each step needs the token from the step before, so the steps cannot run in parallel.

SGLang’s default walk runs them as γ separate steps. Each step is a full-vocabulary GEMV, then an add, an argmax and a sampler. W2 is V × 256 in bf16: 78 MB at Qwen3’s 151,936-token vocabulary, and 127 MB at 248,320. So every round reads the same tens of megabytes from HBM γ times, just to pick one token per step.

That adds up. On an H100 at bs = 1, the walk costs 331 µs per round at γ = 7 and 741 µs at γ = 16. That is 5.8% and 12% of a whole Qwen3-4B speculative-decoding round (5.72 ms and 6.06 ms). The big target model verifies the draft in parallel. This little serial tail is what is left.

The idea: make the walk resident

Two facts about the walk point to the design:

Persistent RNN kernels used exactly this shape ten years ago. They kept the recurrent weights on chip and kept one kernel alive across all time steps (Diamos et al., 2016). We apply the same idea to the draft walk.

First, the matrix has to fit. An H100 has 65.4 MB of on-chip storage across its 132 SMs (registers plus shared memory). bf16 W2 is 78 MB, so it does not fit. An int8 W2 with one fp32 scale per row is 38.9 MB, and that does. (Why int8 rather than fp8 comes later.)

So the kernel works like this:

Three kernels cover the batch sizes:

kernel batch where W2 lives
single bs = 1 int8 mma.sync A fragments in registers and shared memory
small_batch bs 2–4 same layout; one MMA pass serves several requests
wgmma bs 5–64, and every bs at V = 248,320 W2 is the shared-memory operand and the request latents are the register operand; tiles that do not fit on chip stream from L2 through a TMA ring

Results on H100

All numbers in this section: H100 80GB SXM, CUDA 13.1.

The walk alone

CUDA-graph replay, cold L2, µs per call. The baseline is SGLang’s default walk.

V γ bs ours, greedy / T = 1 default walk speedup (greedy)
151,936 7 1 36.8 / 37.9 331.4 9.0×
151,936 7 16 70.5 / 90.1 569.6 8.1×
151,936 7 64 115.7 / 171.5 988.9 8.5×
151,936 16 1 61.7 / 65.6 740.7 12.0×
151,936 16 64 265.5 / 389.4 2243.9 8.5×
248,320 15 1 211.4 / 253.0 971.1 4.6×
248,320 15 64 456.5 / 669.9 3296.7 7.2×

At V = 248,320 the int8 table is 63.6 MB. That is just under the 65.4 MB total, but each SM also needs room for working buffers, so it no longer fits. The wgmma kernel keeps as much as fits in shared memory and streams the rest from L2 at every step. That is why bs = 1 costs 211 µs there (γ = 15), against 62 µs on the Qwen3 vocabulary (γ = 16).

End to end in SGLang

How we measured:

Per-round throughput gain:

drafter temp conc 1 conc 4 conc 16 bs ≈ 64
deepseek-ai/dspark_qwen3_4b_block7 (γ = 7) 0 +5.5% +5.9% +5.3% +3.8%
deepseek-ai/dspark_qwen3_4b_block7 (γ = 7) 1 +5.2% +5.3% +3.5% +3.5%
shreybirmiwal/Qwen3-4B-DSpark-b16 (γ = 16) 0 +10.8% +12.1% +11.2% +6.1%
shreybirmiwal/Qwen3-4B-DSpark-b16 (γ = 16) 1 +10.5% +11.7% +10.5% +4.4%

This is the general pattern: the kernel saves a fixed amount of time per round, so the percentage depends on how long the rest of the round takes. Longer blocks have longer walks and gain more. Bigger targets have longer rounds and gain less.

Correctness

Why int8 and not fp8?

fp8 would fit just as well: it is also one byte per weight, and on H100 the fp8 and int8 tensor cores run at the same rate. We still use int8, for two reasons.

int8 has about 4.6× lower error than e4m3. We quantized W2 of the two public 4B drafters used above, each with one scale per row. The two drafters agree to the digits shown:

format W2 error bias error bias argmax unchanged
int8 0.58% 0.26% 99.5–99.6%
fp8 e4m3 2.64% 1.20% 97.5–97.9%
fp8 e5m2 5.27% 2.40% 94.6–94.9%

The errors are relative Frobenius norms. The bias is W2·u for u = W1 of 4,096 random tokens. The fp8 rows get the same free fp32 scale as int8.

The reason is how each format spends its 8 bits:

int8 sums are exact. int8 × int8 products add up exactly in int32, so the result does not depend on summation order. That is why all three of our kernels, with their different tilings, match one integer reference bit for bit.

The two-plane trick for u relies on this too. It gives u about 16 bits of precision for the price of two int8 MMAs, and the integer sums stay exact. With fp8 you either put u in fp8 as well, with 3 mantissa bits, or convert W2 to fp16 inside the loop. Our first fp8 prototype did the latter.

In an offline acceptance check on one of our own 4B drafters, int8 showed no measurable loss. e4m3 and e5m2 lost very little, but measurably.

This choice depends on the GPU. On H100 and B200, int8 and fp8 tensor cores run at the same rate. Blackwell Ultra (B300) cuts dense int8 throughput to a small fraction of fp8, so on B300 the walk will probably have to use fp8 and live with the larger error.

Tensor parallelism: bf16 with no quantization

int8 is what lets W2 fit on one H100. With two GPUs there is another option: split W2 by vocabulary. Each GPU then holds half of the bf16 table, 38.9 MB, which is the same size as the whole int8 table. So the walk can stay on chip with the head’s own bf16 weights and fp32 accumulation.

The price is one more exchange. Each GPU finds the best token in its own half, and the two halves have to agree before the next step can start. We do this inside the kernel through peer-mapped mailboxes. The cross-GPU hop costs about 1.1–1.6 µs per step.

Round time at γ = 7, device clock, µs, greedy | T = 1:

batch int8, 1 GPU (best kernel) bf16, 2 GPUs
1 32.8 | 33.5 52.0 | 57.3
2 40.7 | 47.5 54.3 | 63.0
4 44.3 | 54.4 55.6 | 64.2
8 65.8 | 84.8 58.9 | 67.6
16 68.8 | 86.0 65.7 | 76.8
32 78.1 | 92.8 72.6 | 80.5
64 114.3 | 144.4 87.2 | 104.6

B200: the bf16 table fits on chip

Blackwell adds a third kind of on-chip memory. Each SM gets 256 KB of tensor memory (TMEM) next to its registers and shared memory, and only the new tcgen05 tensor-core instructions can read it. Across 148 SMs that gives:

H100 SXM B200
SMs 132 148
on chip (registers + shared memory + TMEM) 65.4 MB 112.1 MB
L2 50 MB 126.5 MiB
tensor-core instructions mma.sync, wgmma mma.sync, tcgen05 (no wgmma)

The bf16 W2 for Qwen3’s vocabulary is 77.8 MB. That does not fit on an H100, but it does fit on a B200. So on B200 we keep the whole table in bf16 with no quantization:

It uses the same rounding points as SGLang’s walk: fp32 accumulation, the bias rounded to bf16, then the sum rounded to bf16. On our test data the drafts and corrected logits matched the default walk bit for bit. The accumulation order differs, though, so that is not a guarantee.

A caveat about the numbers. We ran on rented B200s (Modal). We could not lock clocks, and our kernel and the baseline ran in different containers rather than interleaved. Treat the B200 ratios as approximate.

V = 151,936: the whole bf16 table on chip

Walk alone, µs per call, against the fastest SGLang walk for each cell:

γ bs ours (bf16), greedy / T = 1 fastest default walk, greedy / T = 1 speedup
7 1 42.2 / 43.6 152.0 / 152.5 3.6× / 3.5×
16 1 72.2 / 75.3 324.6 / 325.1 4.5× / 4.3×
16 4 97.1 / 109.1 460.9 / 459.8 4.7× / 4.2×
16 8 114.9 / 144.8 543.5 / 581.2 4.7× / 4.0×

The ratios are smaller than on H100. That is because the baseline gets faster too: the whole bf16 table fits in B200’s L2, so the default walk drops from 44.0 µs per step on H100 to 19.2 µs on B200. Our int8 kernel, recompiled for B200, barely moves: 2.73 µs per step on H100, 2.82 on B200.

Keeping the table on chip still matters. A probe that reads the whole bf16 table from L2 every step, inside a single launch, needs 6.76 µs per step. The resident kernel needs 3.34 µs, about 2× faster.

Where one step goes, at bs = 1:

Batching is almost free for the tensor cores, because request n is column n of the MMA’s N = 8 operand. What grows with batch size is everything around the GEMV.

V = 248,320: 62% on chip, the rest streamed

At 248,320 tokens, the bf16 table is 127 MB, and even B200 can only keep 78.8 MB (62%) on chip. Every step, the other 48.5 MB has to come from L2:

bs = 1, µs per call:

γ ours, greedy / T = 1 fastest default walk, greedy / T = 1 speedup
1 27.5 / 28.8 37.2 / 45.0 1.35× / 1.56×
15 116.2 / 119.3 510.1 / 527.3 4.4× / 4.4×

One lesson from this kernel: issue order matters more than you would expect.

Two more things we measured about B200’s tensor cores:

End to end on B200

We have not run an end-to-end A/B on B200 yet. We have only measured how much of a round the default walk takes. With Qwen3-4B and the γ = 16 drafter at bs = 1, it is about 6.4% of a B200 round, against 11% on H100.

Beyond DSpark: other serial heads

DSpark’s Markov head is not the only serial head. Other drafters correct their block with a small recurrent cell that also ends in a vocabulary-wide matrix:

They all have the same shape: a tiny state, and a big matrix that every step reads. So we turned the walk into a template, walk_kernel<Cell>, where each head supplies only its state update.

The question for each head is whether its recurrent matrix fits in the spare shared memory of every CTA:

SGLang runs Domino in one of two modes, and we compare against both:

Our kernel scores the full vocabulary, like the exact mode, at 7.2 µs per step.

H100, bs = 1, µs per round, against each head’s own reference implementation. “Head only” means the round minus the lm_head GEMM, which both sides run identically:

head reference γ = 7, ours / ref γ = 15, ours / ref
DSpark gated (synthetic weights) SGLang GatedMarkovHead 62 / 410–412 (6.6×) 97 / 874–877 (9.0×)
DSpark RNN (synthetic weights) SGLang RNNHead 69 / 480 (7.0×) 113 / 1012–1013 (8.9×)
DeLS-Spec GRU (released weights) the authors’ CUDA-graph runner 73–74 / 501–521 (6.8–7.1×) 132–133 / 1066–1109 (8.0–8.4×)
Domino (released weights) SGLang, exact full vocabulary 68.5–68.8 / 487–488 (7.1×) 126 / 1068–1072 (8.5×)
Domino (released weights) SGLang default, 2,048-token candidate pool 68.5–68.8 / 311–313 (4.6×) 126 / 585–594 (4.7×)
DFlash2 (our 4B reproduction) SGLang top-16 lattice walk 39 / 78 (2.0×) 64.5 / 77–84 (1.2–1.3×)

Domino end to end. We hooked the kernel into SGLang’s Domino path at bs = 1 (Qwen3-4B or Qwen3-8B with the released Huang2020 Domino b16 drafters, temperature 0, concurrency 1, the same 605 prompts):

target vs SGLang exact (pool 0) vs SGLang default (pool 2,048)
Qwen3-4B +16.8% per round (saves 0.96 ms) +8.3% (saves 0.48 ms)
Qwen3-8B +12.0% (saves 1.03 ms) +6.6% (saves 0.56 ms)

Limitations

Code