Skip to content

[CPU][Spec] Support DFlash target-verify for hybrid GDN models - #37851

Open
ekintel wants to merge 3 commits into
sgl-project:mainfrom
ekintel:pr7-dflash-cpu-gdn
Open

ekintel wants to merge 3 commits into
sgl-project:mainfrom
ekintel:pr7-dflash-cpu-gdn

Conversation

@ekintel

@ekintel ekintel commented Sep 3, 2026

Copy link
Copy Markdown

Motivation

This PR enables DFlash speculative decoding for hybrid GDN models such as Qwen3.5-9B on CPU. The CPU AOT mamba ops are decode-only:

  • causal_conv1d_update_cpu has no target-verify variant (no intermediate_conv_window / chain metadata).
  • fused_sigmoid_gating_delta_rule_update_cpu has no target-verify variant (no intermediate_states_buffer).
  • scatter_mamba_states_after_mtp_verify raises ValueError: ... only supports CUDA tensors.

This PR adds those three CPU paths so the DFlash verify step runs end to end on CPU.

Builds on #36782, which enables the DFLASH algorithm on CPU.

Modifications

Modifications

DFlash drafts a linear chain (retrieve_next_token[t] == t + 1, no siblings), so the Triton kernels' ancestor walk degenerates to a rolling window and the draft block is a plain sequential walk over the draft tokens.

  • csrc/cpu/mamba/fla.cppfused_sigmoid_gating_delta_rule_update_spec_cpu, a target-verify variant of the decode kernel. Verify tokens already arrive packed as n * steps + t, so one work item per (sequence, v_head) carries the recurrence forward across t and writes each per-step snapshot straight into intermediate_states_buffer. The committed ssm_states pool is only written at the end, and only when disable_state_update is false.
  • csrc/cpu/mamba/conv.cppcausal_conv1d_verify_cpu, the same idea for the conv. The decode kernel's tinygemm_kernel already keeps its rolling window in registers across the whole block it is handed, and only reads conv_states for the first token, so one call over the full block is arithmetically identical to replaying it per token. A second pass writes the per-token state snapshots and a third commits the final state from the last snapshot. The snapshot pass partitions over the channel axis, not the token axis, because the deduplicated conv window is an overlapping as_strided view in which neighboring tokens share bytes.
  • sgl_kernel/mamba.py — target-verify replay for the conv and SSM ops. Each dispatches the block once through the kernel above, after normalising the input to the token-major layout the kernels stride over. Both validate the chain metadata and raise NotImplementedError on a branching tree rather than silently computing the wrong thing.
  • mamba_state_scatter_triton.py — torch fallback for scatter_mamba_states_after_mtp_verify, reproducing the fused kernels' contract including their silent out-of-range skips (which is how requests with nothing to commit are excluded).
  • gdn_triton.py — bind the CPU implementations on the is_cpu() branch.

Two details worth calling out:

  1. The conv replay must call the C++ kernel, not F.conv1d. The CPU decode path passes is_vnni=true, so layer.conv_weights is already VNNI-prepacked by causal_conv1d_weight_pack and is not usable as a plain [dim, width] convolution kernel. Feeding it to F.conv1d produces garbage with a 0.0 acceptance rate.
  2. intermediate_state_indices arrives pool-sized. build_verify_intermediate_state_indices returns an arange(pool_size) padded table that the kernels read positionally per request, so the SSM path slices the first batch rows. This is benign at batch 1 and crashes at batch 3.

Accuracy Tests

test/registered/cpu/test_causal_conv1d.py and test/registered/cpu/test_mamba.py (registered CPU CI).

Each replay is checked against an independent implementation rather than a transcription of the code under test:

  • conv vs. the C++ decode kernel driven one step at a time (test_verify_matches_cpp_decode_kernel).
  • SSM vs. chunk_gated_delta_rule_cpu (test_chain_verify_matches_chunk_kernel), which solves the same recurrence in matrix form and consumes the draft block as one variable-length sequence — so it also pins the n * steps + t token packing that the replay's slicing assumes.
  • scatter vs. the contract spelled out one request at a time, including the out-of-range rows.

Plus window layout, state-roll, untouched-slot, output-stride and tree-rejection cases.

Both files in full, on this branch, on a Xeon host:

$ python -m pytest test/registered/cpu/test_causal_conv1d.py test/registered/cpu/test_mamba.py -q
22 passed, 17 warnings, 47 subtests passed in 9.37s

End to end, Qwen3.5-9B greedy on CPU produces text identical to the non-speculative baseline.

Speed Tests and Profiling

Qwen3.5-9B, Xeon 6 (128 physical cores), TP4, --attention-backend intel_amx, 20 prompts, greedy, concurrency 1, --speculative-dflash-block-size 16. Custom dataset consists of the first 20 rows of the HumanEval test split.

python -m sglang.benchmark.serving --backend sglang --model Qwen/Qwen3.5-9B \
  --dataset-name custom --dataset-path /tmp/humaneval20.jsonl \
  --sharegpt-output-len 128 --num-prompts 20 --max-concurrency 1 \
  --request-rate inf --warmup-requests 3 --temperature 0 --seed 42 \
  --host 127.0.0.1 --port 30000

============ Serving Benchmark Result ============
Backend:                                 sglang
Traffic request rate:                    inf
Max request concurrency:                 1
Successful requests:                     20
Benchmark duration (s):                  27.11
Total input tokens:                      2041
Total input text tokens:                 2041
Total generated tokens:                  2560
Total generated tokens (retokenized):    2548
Request throughput (req/s):              0.74
Input token throughput (tok/s):          75.29
Output token throughput (tok/s):         94.44
Peak output token throughput (tok/s):    145.00
Peak concurrent requests:                2
Total token throughput (tok/s):          169.73
Concurrency:                             1.00
Accept length:                           6.11
----------------End-to-End Latency----------------
Mean E2E Latency (ms):                   1354.37
Median E2E Latency (ms):                 1229.74
P90 E2E Latency (ms):                    2082.96
P95 E2E Latency (ms):                    2143.17
P99 E2E Latency (ms):                    2172.93
---------------Time to First Token----------------
Mean TTFT (ms):                          88.47
Median TTFT (ms):                        87.99
P90 TTFT (ms):                           99.70
P95 TTFT (ms):                           102.60
P99 TTFT (ms):                           106.68
-----Time per Output Token (excl. 1st token)------
Mean TPOT (ms):                          9.97
Median TPOT (ms):                        8.89
P90 TPOT (ms):                           15.78
P95 TPOT (ms):                           16.19
P99 TPOT (ms):                           16.35
---------------Inter-Token Latency----------------
Mean ITL (ms):                           9.97
Median ITL (ms):                         5.88
P90 ITL (ms):                            19.69
P95 ITL (ms):                            29.47
P99 ITL (ms):                            58.85
Max ITL (ms):                            63.85
==================================================
baseline DFlash
Output throughput (tok/s) 29.24 94.44 3.23x
Mean TPOT (ms) 33.83 9.97
Accept length 6.11

The two fused kernels are what make that number. Replaying the block token by token cost 768 SSM and 768 conv dispatches per rank per step on a 24-layer model at block size 16; folding the SSM replay into one dispatch took the same configuration from 65.44 to 81.53 tok/s, and folding the conv replay took it from 84.15 to 94.44. Accept length is unchanged across all of them, which is the expected signature of replaying the same arithmetic in fewer calls.

Checklist

cc @mingfeima @yizhang2077 @ch-wan


CI States

Latest PR Test (Base): ❌ Run #33905040429
Latest PR Test (Extra): ❌ Run #33905040261
Latest PR Test (AMD ROCm 7.2): ❌ Run #33905040567

The CPU AOT mamba ops are decode-only: causal_conv1d_update_cpu and
fused_sigmoid_gating_delta_rule_update_cpu have no target-verify variant,
and scatter_mamba_states_after_mtp_verify is CUDA-only. That leaves
DFlash unusable on CPU for hybrid GDN models such as Qwen3.5-9B.

DFlash drafts a linear chain (retrieve_next_token[t] == t + 1, no
siblings), so the Triton kernels' parent walk degenerates to a rolling
window and the draft block is the decode kernel replayed token by token.
Both verify paths replay it against a scratch state so the committed pool
only sees the final value, and raise NotImplementedError on a branching
tree rather than silently computing the wrong thing.

The conv path has to replay the C++ kernel rather than call F.conv1d:
the decode path passes is_vnni=true, so layer.conv_weights is already
VNNI-prepacked and is not usable as a plain [dim, width] conv kernel.

Each replay is tested against an independent implementation rather than a
transcription of the code under test -- the conv path against the C++
decode kernel, the SSM path against chunk_gated_delta_rule_cpu, which
solves the same recurrence in matrix form and takes the draft block as
one variable-length sequence, so it also pins the token packing.

Qwen3.5-9B on Xeon, TP4, 20 prompts, greedy, block size 16: output
29.24 -> 65.44 tok/s (2.24x), mean TPOT 33.83 -> 14.70 ms, accept length
6.07. Generated text is identical to the non-speculative baseline.

Builds on sgl-project#36782, which enables DFLASH on CPU.
Target verify replays the draft block one token at a time so that each
token sees the state left by its parent. On CPU that meant block_size
sequential calls into fused_sigmoid_gating_delta_rule_update_cpu per
layer, plus an index_put_ per token to snapshot the state: 768 dispatches
per rank per step for a 24-layer model at block size 16.

fused_sigmoid_gating_delta_rule_update_spec_cpu walks the block inside
the kernel instead. Tokens already arrive packed as n * steps + t, so one
work item per (sequence, v_head) can carry the state forward across t and
write each snapshot straight into intermediate_states_buffer.

The conv snapshots are batched the same way: the state after token t is
the width-1 window of [initial_state, block] ending at t, so all of them
are a single strided copy rather than an index_put_ per token. The conv
outputs still come from the per-token decode kernel, which is what
test_verify_matches_cpp_decode_kernel checks against.

Qwen3.5-9B, tp4, block size 16, 20 HumanEval prompts at concurrency 1:
65.44 -> 84.15 output tok/s, mean TPOT 14.70 -> 11.28 ms. Acceptance
length is unchanged at 6.1.
The verify path replayed the decode conv kernel once per draft token, so a
16-token block issued 16 dispatches per layer per step and rebuilt the
rolling window from the cache each time.

The decode kernel already keeps that window in registers across the whole
block it is given: only the first token reaches back into the incoming
conv state. So one call over the full block computes the same outputs.
causal_conv1d_verify_cpu does that, and in the same pass writes the
per-token state snapshots the verifier needs and commits the final state
from the last snapshot.

The snapshot pass partitions over the channel axis rather than the token
axis, because the deduplicated conv window is an overlapping as_strided
view in which neighbouring tokens share bytes.

Qwen3.5-9B, TP4, block size 16, 20 prompts, greedy, concurrency 1:
84.15 -> 94.44 tok/s, mean TPOT 11.28 -> 9.97 ms. Accept length is
unchanged at 6.11, as expected from replaying the same arithmetic.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant