Skip to content

[Feat] spec: adaptive verify for DFLASH on Qwen3.8 (integration of jianc99/sglang#1 to jianc99/sglang#7) - #8

Open
jianc99 wants to merge 32 commits into
av/basefrom
av/integration
Open

jianc99 wants to merge 32 commits into
av/basefrom
av/integration

Conversation

@jianc99

@jianc99 jianc99 commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Not for merging. This branch merges #1 to #7 so they can be measured and reviewed together; merge those PRs instead.

Motivation

Adaptive verification for DFLASH (DFlash2 block-8 draft) on Qwen3.8-27B, built on upstream DSpark instead of a separate implementation.

Modifications

Merge of, in order:

No other code.

Run with SGLANG_RAGGED_VERIFY_MODE=compact --enable-linear-replayssm-spec --speculative-dspark-sps-table-path <table>; the table comes from the existing offline profiler: python -m sglang.benchmark.dspark_sps_profiler all --base-url ... --fracs 0.25 0.5 0.75 1.0 --max-batch-size <top concurrency> --input-len 1024 (128 at TP1, 512 at TP4 here) against a server started with SGLANG_RAGGED_VERIFY_MODE=compact SGLANG_DSPARK_ENABLE_SPS_RECORD=1 SGLANG_SIMULATE_ACC_LEN=1.0.

Speed Tests

Setup: Qwen3.8-27B target, DFlash2 block-8 draft, GB300, bf16 mamba state, trtllm_mha target attention and fa4 draft attention, overlap scheduling on. SPEED-Bench first turns, up to 4096 output tokens; T=1 is top_p 0.95, top_k 20, T=0 is greedy. TP1 uses --max-running-requests 136; TP4 uses --tp-size 4 --max-running-requests 528 --cuda-graph-max-bs-decode 512 --mem-fraction-static 0.80. The running cap sits above the top concurrency: every level reaches c running requests (c-1 in the sampled maximum at a few TP4 levels), and the mean queue stays under 0.7% of c (1.9-3.4 requests at TP4 c512, while new requests wait for prefill). The client keeps c requests in flight; requests per level: 16 (c1, c2), 24 (c4), 48 (c8), 96 (c16), 192 (c32), 384 (c64), 640 (c128), 512 (c256), 1024 (c512).

Columns:

  • adaptive: this tree, SGLANG_RAGGED_VERIFY_MODE=compact --enable-linear-replayssm-spec with the profiled cost table.
  • static: the same tree, SGLANG_RAGGED_VERIFY_MODE=static --enable-linear-replayssm-spec; every request verifies the full block.
  • upstream main: 96f013b, static DFLASH verify on the FlashInfer MTP GDN path (one state snapshot per draft token).

Decode tok/s = per-request decode rate (output tokens after the first / time after the first token), times concurrency; c1 is the single-request decode rate. Each cell is the mean over runs.

Every adaptive and static run used these caps. The upstream column's top levels (TP1 c64-c128, TP4 c128-c512) come from runs with the same caps; its lower levels come from runs with a cap of 128 / 512, which only matters at the top level. The single-PR measurements in #1 to #7 used a cap equal to the top concurrency (128 / 512). At TP1 c128 that keeps about one request queued and nearly doubles TTFT (about 0.5 s to 1.0 s); at TP4 c512 it adds about one queued request and about 10% TTFT. Both read 7-15% higher decode tok/s for every method, so compare their top levels only within each PR.

TP1, T=1, decode tok/s

c adaptive (compact) static (same tree) upstream main adaptive vs upstream adaptive vs static
c1 328 322 321 +2.2% +1.9%
c2 629 635 618 +1.8% -0.9%
c4 1201 1219 1150 +4.4% -1.5%
c8 2115 2276 2030 +4.2% -7.1%
c16 3538 3761 3261 +8.5% -5.9%
c32 5545 5787 4766 +16.3% -4.2%
c64 7462 7364 5958 +25.2% +1.3%
c128 9074 7932 6422 +41.3% +14.4%

Runs per cell: adaptive 3, static 3, upstream 6 below c64 and 1 from c64 up.

TP1, T=0, decode tok/s

c adaptive (compact) static (same tree) upstream main adaptive vs upstream adaptive vs static
c1 358 377 359 -0.3% -5.0%
c2 687 705 690 -0.4% -2.6%
c4 1283 1380 1296 -1.0% -7.0%
c8 2362 2534 2257 +4.7% -6.8%
c16 3886 4117 3701 +5.0% -5.6%
c32 6078 6306 5338 +13.9% -3.6%
c64 8162 7925 6739 +21.1% +3.0%
c128 9788 8448 7170 +36.5% +15.9%

Runs per cell: adaptive 4, static 4, upstream 6 below c64 and 1 from c64 up.

TP4, T=1, decode tok/s

c adaptive (compact) static (same tree) upstream main adaptive vs upstream adaptive vs static
c1 649 653 651 -0.3% -0.6%
c2 1216 1251 1226 -0.8% -2.8%
c4 2304 2363 2240 +2.9% -2.5%
c8 4124 4353 4029 +2.4% -5.3%
c16 6437 7052 6308 +2.0% -8.7%
c32 9713 10857 9186 +5.7% -10.5%
c64 13465 13887 11815 +14.0% -3.0%
c128 16622 16171 13528 +22.9% +2.8%
c256 20936 18958 15681 +33.5% +10.4%
c512 22019 19580 15993 +37.7% +12.5%

Runs per cell: adaptive 3, static 2, upstream 2 below c128 and 1 from c128 up.

TP4, T=0, decode tok/s

c adaptive (compact) static (same tree) upstream main adaptive vs upstream adaptive vs static
c1 732 761 746 -1.9% -3.8%
c2 1363 1429 1410 -3.3% -4.6%
c4 2643 2683 2739 -3.5% -1.5%
c8 4693 5021 4712 -0.4% -6.5%
c16 7337 8008 7475 -1.8% -8.4%
c32 10890 11900 10708 +1.7% -8.5%
c64 14719 15204 13614 +8.1% -3.2%
c128 18219 17505 15697 +16.1% +4.1%
c256 23597 20642 18570 +27.1% +14.3%
c512 24532 21664 19299 +27.1% +13.2%

Runs per cell: adaptive 3, static 2, upstream 2 below c128 and 1 from c128 up.

Run-to-run spread (max minus min, over the mean) is up to 7.3% below c32 and at most 4.1% from c32 up.

All adaptive and static numbers were measured on this branch's head, which carries every review fix.

How to read the gains:

  • The gain over upstream combines the system changes (ReplaySSM verify and commit, conv tile, radix top-k) with adaptive verification. Adaptive verification alone is the adaptive vs static column: +14.4% / +15.9% at TP1 c128, +10.4% / +14.3% at TP4 c256 and +12.5% / +13.2% at TP4 c512 (T=1 / T=0).
  • Static on this tree is already ahead of upstream: +23.5% / +17.8% at TP1 c128 and +22.4% / +12.3% at TP4 c512 (T=1 / T=0).
  • At high concurrency adaptive verifies fewer drafts per request, so its acceptance length is lower (3.14 vs 3.60 for static at TP1 T=1 c128, 3.06 vs 3.59 at TP4 T=1 c512) and each step is cheaper.

Speedup over no speculation (T=1)

No speculation: the same tree and the same server flags with the speculative flags removed, same client and requests per level.

How the rows were measured:

  • c1-c64 (and TP1 c128): speculative columns are the T=1 tables above. The no-speculation run uses the same caps (TP1 --max-running-requests 136).
  • Top levels (TP1 c256-c512, TP4 c128-c512): all three columns were rerun with --stream-interval 4 and --max-running-requests 528 --cuda-graph-max-bs-decode 512.
  • Why --stream-interval 4: with one streamed chunk per token, no-speculation serving at these levels is limited by the HTTP and detokenizer path, not the GPU. At TP4 c512, time to first token reached 24 s and only about 30% of the c requests were running, so decode tok/s overstated it. With --stream-interval 4 every column keeps 78-90% of c requests running at every one of these levels. Time to first token stays under 1 s at TP4 and under 3 s at TP1. The speculative numbers change by at most 3% between the two settings.
  • TP1 c256-c512 adaptive: uses a cost table profiled up to batch 512. The TP1 table above stops at 128, and above the profiled range adaptive verifies the full block.
  • Runs: one run per no-speculation cell, and one run per column in the top-level rows.

TP1, T=1, decode tok/s

c no speculation static adaptive static vs no spec adaptive vs no spec
c1 108 322 328 2.98x 3.04x
c2 210 635 629 3.02x 3.00x
c4 410 1219 1201 2.97x 2.93x
c8 790 2276 2115 2.88x 2.68x
c16 1458 3761 3538 2.58x 2.43x
c32 2587 5787 5545 2.24x 2.14x
c64 4297 7364 7462 1.71x 1.74x
c128 6485 7932 9074 1.22x 1.40x
c256 9072 8979 10094 0.99x 1.11x
c512 10580 8973 10595 0.85x 1.00x

TP4, T=1, decode tok/s

c no speculation static adaptive static vs no spec adaptive vs no spec
c1 247 653 649 2.64x 2.63x
c2 479 1251 1216 2.61x 2.54x
c4 950 2363 2304 2.49x 2.43x
c8 1814 4353 4124 2.40x 2.27x
c16 3313 7052 6437 2.13x 1.94x
c32 5809 10857 9713 1.87x 1.67x
c64 9266 13887 13465 1.50x 1.45x
c128 13219 16268 16792 1.23x 1.27x
c256 18810 19175 21020 1.02x 1.12x
c512 22858 19746 22424 0.86x 0.98x

Static speculation falls below no speculation at the top levels: 0.85x at TP1 c512 and 0.86x at TP4 c512. Adaptive verification keeps decoding at least on par with no speculation at every level, from 3.04x / 2.63x at c1 (TP1 / TP4) down to 1.00x / 0.98x at c512. Speculation can therefore stay on at any load instead of being switched off at high concurrency.

Known gaps

  • Below the top levels, adaptive is still behind static on the same tree: 1-7% at TP1 c2-c32 (worst 7% at T=1 c8) and 1-11% at TP4 c2-c64 (worst 11% at T=1 c32). Compact CUDA graphs are keyed by token tier with min(T, max_bs) request slots, so a step at batch 8 replays a 64-slot graph (GDN, conv and attention cost grow with slots), and full-block steps are not routed back to the batch-size-keyed static graphs. The same holds at c1: at TP4 T=0 c1 both modes accept 4.03 tokens per step and adaptive is 3.8% slower. Static mode is unaffected. Fix direction: route full-block steps to the batch-size graphs, or capture compact graphs per (request bucket, token tier). Needs a graph-runner change, left for a follow-up (see [Feat] spec: confidence-scheduled compact verify for DFLASH #7).
  • Greedy (T=0) at TP1 c1-c4: compact verifies the full block (no trimming) and runs each step as fast as static, but its acceptance length is lower (3.95 vs 4.17 at c1, 3.82 vs 4.20 at c4), so it decodes 3-7% slower. The packed verify kernels change near-tie greedy tokens, and long greedy outputs then follow different paths; T=1 acceptance is the same in both modes. At TP4 T=0, adaptive is 0.4-3.5% below upstream from c1 to c16.

Tests (on this tree)

  • Unit and kernel tests: 345 passed (DFLASH, DSpark, spec and estimator files) and 455 passed (conv, GDN ReplaySSM, mamba). The only failures are 2 test_gdn_replayssm_spec_fold cases that fail the same way on av/base.
  • compute-sanitizer memcheck on the ReplaySSM circular and direct commit tests (7 tests, 133 subtests): no device-side errors (0 errors with API error reporting off; with it on, the only errors are CUDA API return codes from cuGetProcAddress_v2 and cuFuncSetAttribute).
  • Greedy outputs, TP1, 12 prompts at c1 and 48 at c8 (768 tokens each), compared with plain decoding (no speculation): adaptive is identical on 3/12 and 9/48, static on 3/12 and 11/48, upstream main's static DFLASH on 3/12 and 10/48. Adaptive differs from plain decoding about as often as static speculative decoding does (39/48 vs 37/48 and 38/48 at c8, a small sample).
  • Reverting the estimator's last commit ([Feat] spec: online acceptance estimator for drafts without a confidence head #4) on this tree changes adaptive decode tok/s at TP1 by at most 1.1% at T=0 and at most 3.4% at T=1 (c1-c4, slower without it), 2 runs each, within the run-to-run spread above.
  • Overlap scheduling on vs off in compact mode (measured on an earlier integration build), TP1 T=1, one run each: on is 18% faster in decode tok/s at c64 (7374 vs 6237), with identical planning. TP4 serving runs clean from c1 to c512 at T=1 and T=0.
  • Cost tables were profiled on an earlier integration build with the command above (fitted bias 12.0 ms at TP1, 6.1 ms at TP4); the review fixes keep the default ring-16 path's kernels the same or faster.

Related upstream work

Each PR states its overlap with open upstream PRs. In short:

Do not merge this branch: merge #1 to #7 instead. It carries the conv tile patches twice (#1 and their copies in #6).

Jian Chen added 30 commits October 22, 2026 06:24
Problem: in linear-chain speculative verify, causal_conv1d_update saves the
intermediate conv window after every draft token with three lane-strided
element stores. MambaPool's deduplicated window view (step stride == window
stride) maps those stores onto one shared row per channel, so the same
addresses are written up to three times by scattered narrow stores. On GB300
(dim 10240, bf16, T=8, bs 128) the verify conv takes 106 us with the
deduplicated window versus 39 us with a dense one.

Fix: when the window view's step stride equals its window stride, step t's
window is columns t..t+2 of the row [state[1], state[2], x_0, ..., x_{T-1}].
For a chain (no tree mask) with kernel width 4, write that row once as a
[BLOCK_N, next_pow2(T+2)] tile before the token loop and skip the per-token
stores. Tree verify, other widths and dense windows keep the per-token
stores. The bytes written are the same; the new test checks them bitwise
against the per-token path.

Effect: kernel time on GB300, dim 10240, bf16, T=8: bs 1 5.4 -> 2.5 us,
bs 64 57.4 -> 10.0 us, bs 128 106.5 -> 17.6 us (T=16, bs 128: 322.6 ->
39.1 us). Every CUDA chain verify on a model with a width-4 conv (GDN,
Mamba2) takes this path. The tile store is SubSir's.
…nce head

Confidence-scheduled verification sizes each request's verify window from
per-position acceptance probabilities. DSpark drafts get them from a trained
confidence head. Draft models without one, such as DFlash2, have no
confidence source, so they cannot use the planner.

Add OnlineAcceptanceEstimator, a logistic model calibrated while serving:
p_k = sigmoid(slope * x_k + bias_k). x_k is logit(q_max) of the
temperature-scaled draft distribution at position k (greedy rows at T = 1,
clamped to +-40). One slope is shared by all positions and each position
has its own intercept, starting from 0.5 and -0.2. After verification,
observe() labels the accepted drafts and the first rejected one and adds
them to the Newton (IRLS) statistics. Every 100 calls it solves the
arrowhead system with ridge 1e-3, damps each update by n / (n + 50), drops
non-finite steps and steps that would make the slope negative, and clears
the statistics.

The feature, the accumulation and the refit run in two Triton kernels;
feature_and_predict() also returns the probabilities from the feature
launch. The refit counter is a device tensor and the coefficients are
updated in place, so no call synchronizes with the host. The estimator
therefore works with overlap scheduling and inside CUDA graphs. A single
program applies each update, so ranks that see the same inputs keep
identical coefficients.

Effect: no behavior change on its own; it is the confidence source for
confidence-scheduled verification of such drafts. Replayed over recorded
static DFlash2 traces of Qwen3.8-27B (about 2M draft positions each), the
AUC of the predicted survival against acceptance is 0.898 at T = 1 and
0.921 at T = 0, against 0.886 and 0.920 for the raw q_max product.
Feature, predict and observe take about 10 us of GPU time per step in a
CUDA graph (14 us at bs = 512).
Problem: the deduplicated conv window tile writes [state[1], state[2], x...],
which is the right row only for kernel width 4, but only the Python wrapper
enforced that; a caller that set the flag for another width would get wrong
window bytes. The new test compiled 32 kernel variants (dim is a constexpr),
and it compared conv outputs of two separately compiled kernels bitwise, which
is not guaranteed on every backend the file runs on. The MambaPool comment
still described the kernel's window write as overlapping per-token stores.

Fix: static_assert KERNEL_WIDTH == 4 inside the tile branch. Test one dim
(1000, a partial channel block) instead of two; T in {2, 4, 8, 16} still covers
every tile width. Keep the bitwise checks on the window and conv state, which
are copies, and compare the conv output with the file's update tolerances.
Update the MambaPool comment.

Effect: no runtime change. The test compiles half as many kernel variants.
build_speculative_verify_target_probs divided the whole [rows, vocab]
logits by the temperature and ran torch.topk over the result before
building the top-k/top-p distribution. On a 248k vocabulary this costs
3.7 ms per verify step at 1024 rows (128 requests x 8-token blocks) on
GB300 and allocates an extra [rows, vocab] fp32 tensor. DFLASH, DSPARK,
UNO dense verification and sampling-mask capture all build their target
distribution here.

Select the top-k on the raw logits and divide only the k selected values
by the temperature. A positive temperature keeps the order of the
logits, so the probabilities are unchanged. The selection goes through a
new kernels/ops/sampling/topk.py op, resolved once per platform:
FlashInfer's sorted, deterministic radix top-k when FlashInfer is
available, torch.topk otherwise. It accepts any k up to the vocabulary
size. The DFlash2 selector's private copy of the same FlashInfer call now
uses this op.

When several logits tie at a row's top-k boundary, which of them keep the
mass is chosen deterministically and may differ from torch.topk; the kept
count and the probability values are the same.

At 1024 rows the distribution takes 1.0 ms instead of 3.7 ms, at 4096
rows 3.2 ms instead of 14.1 ms, and the peak temporary memory halves.
…te slot

Problem: the GDN spec-verify ring had no length check. The verify writes the
draft window at ring positions [write_pos, write_pos + T) and drops stores
past the ring, so a ring shorter than the draft window (for example
--speculative-num-draft-tokens 24 with the default
--linear-replayssm-cache-len 16) made the commit fold a truncated window. A
ring shorter than 16 (for example 8 for a block-8 draft) passed startup and
then failed to compile the compact commit at the first verify, because the
commit reduces over the whole ring with tl.dot.
Separately, the cursor advance decided which rows are live from the request
row, while the verify, the compact commit and the finish kernel decide it
from the state slot. A committed row with a request row but state slot -1
had write_pos and is_flush advanced, was then skipped by the commit and the
finish, and its next verify returned without writing its output.

Fix: when the GDN spec ring is allocated, require a power-of-two
--linear-replayssm-cache-len of at least 16 that holds the draft window, and
name the flag in the error. Pass the state slots to the cursor advance and
skip rows whose slot is at or below the null block, as the other three
kernels do. The help of --linear-replayssm-cache-len states the constraints.

Effect: a ring that cannot hold the draft window or the commit now fails at
startup instead of truncating the window or failing at the first verify;
this covers every algorithm on the GDN spec ring, EAGLE and NGRAM included.
The four kernels agree on the live rows. No served output changes for
configurations that fit the ring.
Problem: DSPARK with SGLANG_RAGGED_VERIFY_MODE=compact packs per-request
verify lengths into one token list, but GDN target verify views a dense
[batch, draft tokens] window from the conv on. Nothing refused that
combination: the compact CUDA graphs fall back to eager for GDN, and eager
verify misreads the tokens. On Qwen3.6 the first trimmed step was reported
to hit an illegal memory access.

Fix: refuse SGLANG_RAGGED_VERIFY_MODE=compact when the GDN backend is built
for a target whose speculative algorithm builds ragged layouts.

Effect: the unsupported combination fails at startup with a message naming
SGLANG_RAGGED_VERIFY_MODE=static or cap-accept. Static and cap-accept
verify (cap-accept verifies the dense window and only trims acceptance),
draft workers and other algorithms are unchanged.
Problem: the ReplaySSM spec verify applied causality by multiplying with
zeros: the (I + A)^-1 row update, D = R T^T and the output D F each
multiplied later positions by exact zeros. The compact commit loaded the
whole ring and gave the rows past the committed history a zero decay the
same way. A non-finite activation at a rejected draft therefore turned the
accepted outputs before it, and the committed state, into NaN, where the
snapshot verify keeps both clean.

Fix: select the causal terms with tl.where instead of multiplying them by
zero, and mask the commit's ring loads to the committed history.

Effect: rejected drafts cannot change accepted outputs, ring records of
accepted positions, the state or later steps, whatever values they hold. On
finite inputs the selects change the rounding of a few sums: over 12
chained steps at Qwen3.8-27B shapes with a bf16 state, 1e-5 to 3e-5 of the
verify outputs differ, by up to 4e-3 of the largest output, and the chain
error against a float64 reference is unchanged. The verify takes 4% longer
with a bf16 state and 1% longer with an fp32 state (batch 128, GB300). The
commit no longer reads the ring rows past the committed history; its load
masking is the same as in the open upstream PR sgl-project#42688.
Problem: DSPARK and DFLASH commit verified linear-attention states through
HybridLinearAttnBackend.update_mamba_state_after_mtp_verify, which only knew
the KDA ReplaySSM fold. On GDN models with --enable-linear-replayssm-spec the
verify already writes the compact request ring, but this commit would scatter
per-draft snapshots that ReplaySSM does not allocate, so the configurator
refused GDN with DSPARK/DFLASH, and those models had to keep a full state
snapshot per draft token.

Fix: move the GDN ring commit (cursor advance, fold of the accepted window
into the checkpoint, conv rollback) into one helper,
commit_gdn_replayssm_after_verify, used by the generic spec commit and by the
backend commit. The backend commit folds every accepted window, whatever the
state dtype, so the checkpoint is the committed state after each step, as in
the KDA fold of the same function; radix donation and state copies read the
slot directly. Only the lazy fold reads the draft length (for its early
flush), so the helper takes max_spec_len only without fold_every_commit.
Remove the configurator refusal, and assert before the snapshot scatter that
snapshots exist. Compact verify stays refused on GDN.

Effect: Qwen3.5/3.8 GDN models run DFLASH and DSPARK (static or cap-accept
verify) with --enable-linear-replayssm-spec. The verify reads each state once
and the commit reads and writes it once instead of writing a snapshot per
draft token, and the snapshot memory is not allocated. The generic spec
commit launches the same kernels as before.
… history

Problem: the ReplaySSM spec verify replayed committed history for every row:
two history dots per K tile and two after the loop, plus the history key,
update and gate loads. A row folded at its last commit has no history, and
DSPARK, DFLASH and the generic commit of a bf16 state fold every commit, so
all of that work added exact zeros. The history decay and scaled updates
were also built before the K loop and stayed live across it.

Fix: run the history terms only when write_pos is nonzero, and build the
decay and scaled updates after the K loop, next to their only use.

Effect: per layer at batch 128 on GB300 (Qwen3.8-27B shapes, block 8), the
verify without history takes 0.109 ms instead of 0.116 ms with a bf16 state
and 0.154 ms instead of 0.188 ms with an fp32 state; with history (the lazy
fold of an fp32 state) 0.209 ms instead of 0.250 ms. A bf16 state with
history, which no commit path produces, takes 9% longer. Outputs, ring
records and states are bit-identical with a bf16 state; with an fp32 state
the compiled TF32 dots round differently (up to 8e-4 of the outputs differ,
by up to 4.4e-3 of the largest output). A new chain test drives the lazy
fold, so verifies replay up to 16 records of history against a float64
reference.
… layouts

Problem: only the Python wrapper kept tree verify off the deduplicated conv
window tile; a caller that set both flags would get the tile plus the tree
branch's per-token stores into the same window. The wrapper comment tied the
tile to MambaPool's overlapping view, while the real argument is about the
strides alone. Kernels that do not take the tile still got a seqlen-dependent
NP2_WINDOW_COLS. The test did not cover a num_accept_tokens state offset,
MambaPool's axis-0 window layout, or an odd T.

Fix: static_assert that the tile branch has no tree mask, next to the width
assert. Rewrite the comment: with step stride == window stride == s, the
per-token stores write row column t + w at (t + w) * s and the tile writes
column c at c * s, the same addresses and values for any layout with those
strides. Pass NP2_WINDOW_COLS=1 when the tile is off. Test T = 3, a
num_accept_tokens offset, and the axis-0 window (a [T + K - 2, dim] row seen
transposed); T = 3 replaces T = 4 in the plain cases, so T + 2 still pads to
tiles of 4, 8, 16 and 32.

Effect: no runtime change. The test compiles 24 kernel variants instead of 16.
The refit dropped any slope step that would make the slope negative. From a
slope well above the optimum the predictions saturate, the curvature is
small and the damped Newton step overshoots past zero. The next round
starts from the same slope and drops the same step, so the slope never
moves: from 3.0 on drafts generated with slope 1.0, and from the default
0.5 on drafts whose feature carries almost no signal (slope 0 or 0.03), it
stayed where it started through every refit.

Halve such a step until the slope stays positive, at most 8 times, and drop
it only if it still crosses zero. The intercepts take their Newton step
given the slope step actually taken. The rule runs in the same single
program, so the refit stays deterministic, sync-free and graph-capturable.

Effect: within ten refits the slope reaches 0.96 in the first case and
0 to 0.06 in the second. Refits whose full step keeps the slope
positive are unchanged; from the default start on drafts generated with
slope 1.3 no step crosses zero. observe() GPU time is unchanged at the
default refit interval and within 0.1 us when every call refits.
…selects

Problem: the ReplaySSM spec verify keeps rejected drafts out by selecting
the causal products, D + where(m, R * T, 0). The select sits between the
multiply and the add, so they no longer fuse, and on finite inputs the
(I + A)^-1 row update, D = R T^T and the output D F rounded differently
than before the masking: over 12 chained steps at Qwen3.8-27B shapes, 1e-5
to 3e-5 of the verify outputs changed with a bf16 state (draft windows of
4 to 16), along with the ring records and the states folded from them.

Fix: select the fused update instead, where(m, tl.fma(R, T, D), D), and
mask the operand rather than the product in the (I + A)^-1 reduction. An
implicit D + R * T inside the select is not enough: at a window of 16 the
compiler still splits some of them.

Effect: on finite inputs the outputs, ring records, committed states and
cursors are bit-identical to the verify before the masking, for draft
windows of 4, 8 and 16 with a bf16 state, which folds every commit (GB300).
With an fp32 state, which also takes the lazy fold of the generic spec
commit, they are bit-identical when measured without the previous commit;
on this branch that commit's TF32 rounding change remains. Non-finite
rejected drafts stay contained. At batch 128 the verify takes 0 to 7% less
time than with the split products: 79 instead of 85 us with a bf16 state at
a window of 4, 372 instead of 383 us with an fp32 state at 16. The
non-finite rejected draft test now also runs the lazy fold of the generic
spec commit, over four steps with committed history before the second
poisoned window.
…d_verify

Problem: the refusal test named the algorithms that build ragged verify
layouts and expected DFLASH to pass the GDN check. The check keys on
supports_ragged_verify(), so the test breaks as soon as another algorithm
gains ragged verify, although the check itself is unchanged.

Fix: give the test runner an algorithm stub whose supports_ragged_verify()
returns the case's value, and cover a ragged target, a non-ragged target and
a draft worker.

Effect: test only; no behaviour change. The test no longer depends on which
algorithms support ragged verify.
Problem: gdn_replayssm_compact_commit_kernel ran one CTA per (64-column V
tile, request, value head x layer), 590k CTAs per step at batch 128 on a
48-layer, 48-value-head GDN model. Every CTA reloaded the request scalars,
ring positions and key tile, and folded the history with four chained
tensor-core products (key and key residual times the high and low update
parts). With a bf16 checkpoint the commit folds every step, and it took
5.40 ms per step at batch 128.

Fix: one program per (request, layer, key head) covers the HV / H value
heads that read that key head, in 32-column V tiles walked by one flat loop.
The history is folded 16 steps at a time: the key and key-residual rows and
the high and low update parts of a chunk are stacked along the reduction
axis, so each chunk is one tensor-core product with fp32 accumulation, and
the tile size no longer depends on the ring length. A ring of one chunk
(the default 16) loads its key tile once per program; longer rings visit
only the chunks that hold history. fp32 rings carry no low part. Grouping
alone and stacking alone do not help (0% and 44% slower); the gain needs
both. Fold and track snapshots are computed for every row and masked at the
store. Slot and layer strides, null rows, the early exit and the cursor
finish are unchanged. The result differs from the previous kernel by fp32
summation order only: at most one bf16 ulp, or 2.8e-6 for fp32 checkpoints
with bf16 rings (up to 1.6e-4 with fp32 rings, TF32 operands).

Effect: GB300, 48 layers, K = V = 128, ring 16, bf16 checkpoint, 4 history
steps, every row folding:
- HV 48 / H 16 (TP1): bs128 5.40 -> 3.85 ms, bs8 341 -> 253 us,
  bs1 39.8 -> 42.0 us.
- HV 12 / H 4 (TP4): bs128 1.35 -> 0.97 ms, bs8 90 -> 72 us,
  bs1 11.5 -> 17.9 us.
With half of the rows taking a track snapshot, bs128 TP1 goes from 10.07 to
7.37 ms. Shared memory no longer grows with the ring: at most 37 KB and no
spills, where the previous kernel needed 98 KB at ring 128 and spilled with
track rows. On a raised --linear-replayssm-cache-len, a 4-step fold at
bs128 TP1 goes 5.8 -> 7.5 ms at ring 32, 8.5 -> 8.1 ms at ring 64 and
13.8 -> 8.2 ms at ring 128. Folding a long history there is slower (ring
64, 56 steps: 9.3 -> 33 ms); only fp32 checkpoints fold that much at once.
Problem: the head-group commit kernel replaced the per-head kernel for
every ring length. It is faster on the default 16-step ring, but on a
raised --linear-replayssm-cache-len it folds the history 16 steps at a time
and its time grows with the ring length and the history. At batch 128 with
TP1 heads it is 1.2-2.7x slower than the per-head kernel at ring 32, and
3.1-5x slower when a full ring of 64 or 128 steps folds (for example, bf16
checkpoint, head group vs per head: 34.9 vs 9.6 ms at ring 64, 65.4 vs 17.3
ms at ring 128). A nearly full ring (all but one draft window) is what a
fold at capacity commits, as with fp32 checkpoints on EAGLE and MTP.

Fix: keep the per-head kernel and pick the kernel on the host from the ring
length. A ring of one 16-step fold chunk commits per head group, a longer
ring per value head. The ring length is a static shape, so the choice needs
no sync, is the same on every TP rank, and does not depend on the batch:
the two kernels differ in fp32 summation order, so a batch-size rule would
make a request's checkpoint depend on the batch it runs in. The circular
commit tests run every case through both kernels, and a new test checks
which kernel each ring length launches.

Effect: GB300 kernel time, 48 layers, K = V = 128, bf16 rings with
residuals, every row folding, batch 128, TP1 heads, bf16 checkpoint, before
-> after:
- ring 16: unchanged, 3.87 ms (per-head kernel: 5.45 ms).
- ring 32: 4 history steps 7.58 -> 5.79 ms, full ring 15.8 -> 5.84 ms.
- ring 64: 4 steps 8.17 -> 8.47 ms, full ring 34.9 -> 9.62 ms.
- ring 128: 4 steps 8.32 -> 13.8 ms, full ring 65.4 -> 17.3 ms.
fp32 checkpoints, track rows, batch 1 and 8 and TP4 heads follow the same
pattern: full rings 2.4-7x faster, every history on ring 32 1.2-3.9x
faster, and 4- to 16-step histories on rings 64 and 128 up to 1.7x slower.
Rings longer than 16 fold short histories only when every commit folds,
where the ring only has to fit one draft window, or in a track snapshot
taken soon after a lazy fold. On the 16-step ring the head-group kernel is
still up to 12 us slower at batch 1 (TP4 heads, fp32 checkpoint, track
rows: 45.1 vs 33.5 us for the per-head kernel).
Problem: in linear-chain speculative verify, causal_conv1d_update saves the
intermediate conv window after every draft token with three lane-strided
element stores. MambaPool's deduplicated window view (step stride == window
stride) maps those stores onto one shared row per channel, so the same
addresses are written up to three times by scattered narrow stores. On GB300
(dim 10240, bf16, T=8, bs 128) the verify conv takes 106 us with the
deduplicated window versus 39 us with a dense one.

Fix: when the window view's step stride equals its window stride, step t's
window is columns t..t+2 of the row [state[1], state[2], x_0, ..., x_{T-1}].
For a chain (no tree mask) with kernel width 4, write that row once as a
[BLOCK_N, next_pow2(T+2)] tile before the token loop and skip the per-token
stores. Tree verify, other widths and dense windows keep the per-token
stores. The bytes written are the same; the new test checks them bitwise
against the per-token path.

Effect: kernel time on GB300, dim 10240, bf16, T=8: bs 1 5.4 -> 2.5 us,
bs 64 57.4 -> 10.0 us, bs 128 106.5 -> 17.6 us (T=16, bs 128: 322.6 ->
39.1 us). Every CUDA chain verify on a model with a width-4 conv (GDN,
Mamba2) takes this path. The tile store is SubSir's.
Problem: the deduplicated conv window tile writes [state[1], state[2], x...],
which is the right row only for kernel width 4, but only the Python wrapper
enforced that; a caller that set the flag for another width would get wrong
window bytes. The new test compiled 32 kernel variants (dim is a constexpr),
and it compared conv outputs of two separately compiled kernels bitwise, which
is not guaranteed on every backend the file runs on. The MambaPool comment
still described the kernel's window write as overlapping per-token stores.

Fix: static_assert KERNEL_WIDTH == 4 inside the tile branch. Test one dim
(1000, a partial channel block) instead of two; T in {2, 4, 8, 16} still covers
every tile width. Keep the bitwise checks on the window and conv state, which
are copies, and compare the conv output with the file's update tolerances.
Update the MambaPool comment.

Effect: no runtime change. The test compiles half as many kernel variants.
Problem: under ragged (compact) speculative verify each request owns a
variable number of packed verify tokens, but causal_conv1d_update only takes
a dense [batch, dim, seqlen] window. A caller has to scatter the packed
tokens into a zero-padded dense buffer and gather the output back on every
layer.

Fix: optional query_start_loc and max_seqlen. With them x and out are packed
[num_tokens, dim]; sequence i owns rows query_start_loc[i] to
query_start_loc[i + 1], at most max_seqlen of them. Each sequence reads its
own rows (zeros past its length) and updates the conv state and the
intermediate windows, per-token or deduplicated tile, exactly like a dense
row zero-padded to max_seqlen; output is written only for its own rows. A
sequence with no rows exits right after the slot check, and packed rows past
the last sequence are never touched. Linear chains only (no tree mask, no
num_accept_tokens). The packed layout and the empty-row exit follow SubSir's
packed verify conv.

Effect: a packed call matches the zero-padded dense call bit for bit (real
output rows, conv state, both window layouts, CUDA-graph replay), so ragged
verify needs no per-layer staging around the conv. GB300, dim 10240, bf16,
T=8, deduplicated window, bs 128: dense 17.4 us (unchanged), packed with
every row full 18.1 us, packed with mixed lengths (mean 4.6) 17.0 us.
Problem: GDN target verify views the packed tokens as a dense [batch, draft
tokens] window from the conv on, so compact verify (SGLANG_RAGGED_VERIFY_MODE
=compact, which packs a variable number of verify tokens per request) is
refused on GDN models and Qwen3.5/3.8 always verify the full draft block.

Fix: with --enable-linear-replayssm-spec under compact verify, every target
verify reads its packed tokens in place: the conv reads them by
query_start_loc, and the ReplaySSM spec verify, which already reads rows by
query_start_loc, takes the packed q/k/v views. Nothing is staged to a dense
[batch, draft tokens] buffer.
- The path is chosen once at startup, not from the batch, so a CUDA graph
  captured with any layout (including a uniform one) replays every other. A
  uniform layout gives the dense result bit for bit.
- GDN reports ragged verify graph support only for this path. The snapshot
  verify (FlashInfer MTP or Triton recurrent) stays dense, and the startup
  refusal of compact verify now points at --enable-linear-replayssm-spec.
- The ReplaySSM spec predicate is computed once in the backend constructor
  instead of in two places.
- The ReplaySSM verify writes zeros to packed rows past the last request
  (CUDA-graph token-tier leftovers), so every output row is finite. Rows of
  graph padding (mamba slot -1 or no tokens) were already zero or skipped.
- The ragged ReplaySSM startup check keeps requiring a ring-writing verify
  backend on KDA only; the GDN ReplaySSM verify writes the ring under every
  verify backend.

Effect: DSPARK (and DFLASH once it produces compact layouts) can run compact
verify on Qwen3.8 with CUDA graphs. A ragged step matches the dense step of
the same real tokens bit for bit (outputs, ring records, committed state and
conv), in eager mode and in graph replay, including padding rows, uncovered
tier leftovers and an extended last row. Static verify outputs for real rows
are unchanged; output rows of padding slots in padded dense graphs are now
zero.
… layouts

Problem: only the Python wrapper kept tree verify off the deduplicated conv
window tile; a caller that set both flags would get the tile plus the tree
branch's per-token stores into the same window. The wrapper comment tied the
tile to MambaPool's overlapping view, while the real argument is about the
strides alone. Kernels that do not take the tile still got a seqlen-dependent
NP2_WINDOW_COLS. The test did not cover a num_accept_tokens state offset,
MambaPool's axis-0 window layout, or an odd T.

Fix: static_assert that the tile branch has no tree mask, next to the width
assert. Rewrite the comment: with step stride == window stride == s, the
per-token stores write row column t + w at (t + w) * s and the tile writes
column c at c * s, the same addresses and values for any layout with those
strides. Pass NP2_WINDOW_COLS=1 when the tile is off. Test T = 3, a
num_accept_tokens offset, and the axis-0 window (a [T + K - 2, dim] row seen
transposed); T = 3 replaces T = 4 in the plain cases, so T + 2 still pads to
tiles of 4, 8, 16 and 32.

Effect: no runtime change. The test compiles 24 kernel variants instead of 16.
Problem: compute_spec_mrope_positions views the target-verify positions as
[bs, tokens / bs]. A ragged verify packs verify_lens[i] tokens for request i
plus graph padding, so a text-only batch fails the view whenever the token
count is not a multiple of bs, and a multimodal batch adds each request's
mRoPE delta to the wrong tokens.

Fix: keep the positions flat. Text-only batches need no per-request split.
Multimodal batches expand the per-request deltas by the ragged layout's
verify lengths (graph padding gets delta 0), with a device-side search so no
lengths are read back on the host; uniform blocks keep the even split.

Effect: mRoPE targets (Qwen3.5/3.8) can run DSpark-style ragged verify. Uniform
blocks compute the same positions as before.
… rank

Problem: under overlap scheduling the verify budget is planned from the
confidence relayed two steps back through a pinned host ring. The relay
returned None when that slot's copy event had not completed yet, which is
rank-local timing: one TP rank could plan a budget while another planned
none, so the ranks entered different collectives and verify graph tiers.
The ring copy also read confidence_buf on a side stream with no ordering
against the next step's scatter into the same buffer.

Fix: wait for the slot's copy instead of skipping it, and make the scatter
wait (device side) for the previous ring copy. This is the source change of
the open upstream PR sgl-project#42717 (without its new test); drop
this commit if that one lands first.

Effect: every rank resolves the same lagged confidence, so all ranks plan
the same budget. The wait is on a copy issued two steps earlier and is
normally already complete.
Problem: DSpark's adaptive verification (SGLANG_RAGGED_VERIFY_MODE
cap-accept / compact: a per-step verify budget from a cost table, top-k
verify lengths by survival, packed ragged verify graphs) is open to DSPARK
only, and it needs a trained confidence head. DFLASH drafts, including
DFlash2 selector drafts, have no confidence head, so they always verify the
full block.

Fix: run DFLASH through the existing DSpark machinery, with an online
acceptance estimator as the confidence source.
- The DFLASH worker builds the DSpark verify planner (with the worker's own
  confidence) and the target-verify executor, provides the overlap
  budget-prepare hook, and publishes its confidence into the existing relay.
  DSPARK-only gates (supports_ragged_verify, the confidence relay) cover the
  DFLASH family.
- Feature: logit(q_max) of the selector row each draft token was drawn from,
  at the request's temperature (temperature 1 for greedy rows); it scores the
  draft distribution, never the drawn token. The draft cuda graph computes it
  next to the walk. After verify the estimator observes the verified and
  accepted draft counts.
- Every DFLASH accept path stops at the planned verify length: the selector
  sampling accept passes the cutoff to DSpark's sampling kernel, and the
  greedy accept caps through AcceptGreedy, which now also takes precomputed
  argmax ids. The bonus comes from the last verified row.
- The ragged window reads the anchor at the block's real row stride (DFLASH
  blocks are gamma + 1 wide, DSpark's gamma).
- TP: like a DSPARK confidence head, the estimator gives every rank the same
  confidence for the same draft (its updates are deterministic and see the
  broadcast acceptance), and the relay resolves the same lagged copy on every
  rank, so every rank plans the same budget and graph tier. The lengths are
  broadcast from rank 0, as for DSPARK.
- Compact verify scatters the packed rows to the block layout row by row,
  which works on a vocabulary shard as well, so DFLASH keeps its deferred
  target-verify gather in every mode; the logit adjustments run once, on the
  gathered logits, for both verify paths.
- Ragged modes need a selector (DFlash2) draft and are refused under
  attention DP. Compact mode is refused at startup when the target attention
  backend cannot verify packed ragged tokens (supports_ragged_verify_graph),
  as a GDN hybrid target cannot yet.

Effect: SGLANG_RAGGED_VERIFY_MODE=cap-accept and compact work for DFLASH with
the same planner, cost table, allocation and compact graphs as DSPARK. The
default static mode is unchanged.
Problem: the verify cost table that plans the compact verify budget comes
from python/sglang/benchmark/dspark_sps_profiler.py, which only accepts
DSPARK servers, and the scheduler only serves the per-step records and the
pinned-budget control it reads to DSPARK workers. With a flat
(uninitialized) table, compact mode also ignores the pinned budget the
profiler's --fracs cells set, so those cells all measure the full verify.

Fix: accept DFLASH servers in the profiler, serve the step records and the
pinned budget to the DFLASH worker (it records the same steps), and let a
pinned budget schedule its lengths without a cost table. This also applies
to DSPARK in compact mode, so the scheduler now refuses to pin a budget when
SGLANG_SIMULATE_ACC_LEN is above 0 and not 1.0: a simulated acceptance could
run past the trimmed lengths.

Effect: DFLASH builds its cost table with the same profiler run as DSPARK.
Serving without a pinned budget is unchanged.
Compact verify planned, packed and scattered every decode step, even when
the plan could only replay the compact graph of the full block: a single
request always lands on that graph, and a small batch often does. That
per-step host work (budget pricing, top-k lengths and their broadcast, the
packed window, the compact-to-block scatter) cost about 1.3 ms at TP4 batch
1 and made the step host-bound, so c1-c8 ran up to 20% slower than static
verify.

A batch whose shortest plan already replays the full block's graph now
prices no budget, and a plan whose graph tier is the full block's takes the
cached uniform layout, as verify-all does. A full-block layout skips the
packing: its verify rows are the verify window, and its logits and hidden
states are already in the block layout.

TP4, T=1, decode tok/s at c1/c2/c4/c8: 520/1033/1970/3800 ->
628/1139/2205/4029 (static 652/1229/2343/4336). c64, c128 and c512 are
unchanged.
# Conflicts:
#	python/sglang/kernels/ops/mamba/causal_conv1d_triton.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant