Conversation
`accept_greedy` takes the target prediction with `torch.argmax` over the verify logits. At DSpark block 5 that tensor is [6, 129280] fp32 -- 3.1 MB, six very wide rows -- and `at::native::reduce_kernel` gives each row a single block, so it needs 23.6 us on GB300 for a reduction that reads 3.1 MB. The accept sits on the critical path between the target verify and the KV commit, so the whole cost is exposed. A flat two-stage split (64 partials per row, then a 64-wide final) does the same reduction in 3.5 us. Ties resolve to the lowest index, matching `ArgMaxOps`' strict `>`, so the result is bitwise identical to `torch.argmax`: 0 mismatches over 300 randomized [6, 129280] cases including exact ties, -Inf and +Inf. The helper only takes over for few-row/wide-vocab fp32 rows and falls back to `torch.argmax` everywhere else; like the tensor it is given, it takes the row stride rather than assuming one. -46 us per verify cycle on DeepSeek-V4.1-Flash, 4xGB300, BS=1.
BBuf
requested review from
DarkSharpness,
HaiShaw,
HydraQYH,
JustinTong0323,
Qiaolin-Yu,
Ying1123,
celve,
hnyls2002,
merrymercy,
mickqian,
yctseng0211,
yhyang201 and
yuan-luo
as code owners
September 13, 2026 12:26
BBuf
requested review from
Fridge003,
hebiao064 and
ispobock
as code owners
September 14, 2026 01:26
`vision_topk` is the router a DeepSeek-V4.1 checkpoint with a vision tower takes for *all* of its MoE layers, not just the vision ones. It calls `moe_fused_gate` without the `packed_out` argument the text path passes, so every MoE layer paid a separate `PackTopkIds` launch to rebuild what the gate already had in registers. Pass the buffer and return `StandardTopKOutputPacked`, under the same admission `_fused_gate_emits_packed_ids` uses on the text path: only the flashinfer_mxfp4 runner consumes the packed form, and nothing may rewrite ids or weights after the router -- which rules out the fused-shared-expert slots `_scale_fused_shared_weights` rescales just below. Same ids, one fewer launch per layer. -64 us per verify cycle on DeepSeek-V4.1-Flash, 4xGB300, BS=1.
On the cross-layer mHC path the collapsed, normalized attention input is written BF16 by `hc_combine_norm` and then immediately re-read by a standalone FlashInfer `mxfp8_quantize` inside the first projection. At BS=1 the two launches cost about the same, even though the second one only re-reads what the first just wrote. Add an MXFP8 epilogue to the combine+norm kernel and hand the result to the attention as the `Mxfp8SwizzledInput` it already accepts. The scale factors use FlashInfer's 128x4 swizzle and the same UE8M0 conversion, and the values come from the same BF16 rounding the standalone pair produces, so both outputs are bitwise identical to `hc_combine_norm` followed by `mxfp8_quantize(..., is_sf_swizzled_layout=True)`: 0 mismatches over 200 randomized cases across five magnitude decades, against both the `cuda` and the `cute-dsl` FlashInfer quantizer backends. The epilogue is only taken when the attention's first projection actually consumes a swizzled MXFP8 tuple (`accepts_mxfp8_swizzled_input`) and the existing fused combine+norm fast path applies; otherwise nothing changes. -56 us per verify cycle on DeepSeek-V4.1-Flash, 4xGB300, BS=1.
BBuf
force-pushed
the
bbuf/dsv41-bs1-dspark-opt
branch
from
September 14, 2026 01:36
58ccbaa to
e6599d3
Compare
BBuf
force-pushed
the
bbuf/dsv41-bs1-dspark-opt
branch
from
September 14, 2026 01:51
e6599d3 to
7dc95e1
Compare
…05 -> 7.44 us) `sparse_decode_fwd` builds its tile-scheduler metadata itself whenever it is handed none, in a `<<<1, 32>>>` kernel whose entire partition loop runs on thread 0 and stores each 32-byte entry straight to global memory. The loop is over `num_sm_parts` = `num_sms / s_q`, so a BS=1 step on GB300 walks 152 partitions at ~156 ns each: 28.05 us measured in the decode graph, where it is fully exposed on the critical path. It cannot be hoisted out of the graph. `on_after_cuda_graph_warmup` clears the scheduler before capture on purpose, because the schedule depends on the step's `topk_length` and a frozen one would leave the tail of a longer row unattended. So the fix is to make the schedule cheap, not to compute it less often. `decoding_sched_meta` runs the same algorithm over a whole block: * the per-request pass and the final write-out are spread over 256 threads; * the request being consumed changes only `batch_size` times across the walk, so its three values stay in registers instead of being re-read from shared memory once per partition -- that chain of dependent loads with one thread and no ILP to hide them is what the 156 ns actually was; * the walk stops when the last request is consumed. Every partition after that describes the same empty range, and the block fills them in parallel. At BS=1 that is most of them: 152 partitions over 5 or 6 query rows leaves fewer than 50 doing any work. The result is fed to FlashMLA as the cached metadata, so it skips its own kernel. Only computed where FlashMLA would have computed -- when the scheduler holds no buffers yet -- so the calls that already reuse a schedule are untouched. In the decode graph: 80 launches per 20 steps either way, 28.05 us each before and 7.44 us after, and no `get_mla_metadata_kernel` left in the trace. Bit-identical to what it replaces, with a new registered test: the schedule itself over batch 1..64, s_q 1 and 6, top-k 512 and 2048 and five `topk_length` shapes including all-zero and all-one rows; the attention output compared as raw bits (a random fp8 cache decodes to NaN in places); and the extra-cache schedule, which rounds the main length up to a block before adding the extra one. FlashMLA's `DecodingSchedMeta` ends in a `_pad` word it never writes, so the comparison covers the seven defined fields. DeepSeek-V4.1-Flash, 4xGB300, BS=1. Isolated A/B over the env switch, 3 launches x 7 runs per arm: 911.30 -> 922.20 tok/s (+1.20%), every launch pair positive. (That branch also carried a benchmark-only change since dropped, so the absolute figures sit above this PR's; the delta is the same either way.)
BBuf
force-pushed
the
bbuf/dsv41-bs1-dspark-opt
branch
from
September 14, 2026 02:04
7dc95e1 to
01bab86
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
A BS=1 latency round on DeepSeek-V4.1-Flash / 4xGB300 (attention TP4, MoE TP4
--ep-size 1, DSpark static verify block 5). Profiling the verify cycle turned up fourplaces where the critical path paid for work it did not need. Each is independent, small,
and individually revertible.
Result
Protocol: 4xGB300 (sm103a), random 4096-in / 1024-out, BS=1 serial client,
SGLANG_SIMULATE_ACC_LEN=5.5+match-expected,--speculative-algorithm DSPARK --speculative-dspark-block-size 5, no profiler attached. Metric is the client-sideoutput_tpsmedian. Base is this PR's merge base,0ea9492; each launch is one warmupplus 7 measured runs, and the two arms were launched alternately on the same box in the
same session.
0ea9492887.10 -> 905.40 tok/s (+2.06%). All three launch pairs improve (+2.50 / +1.64 /
+2.41%). Three launches per arm because the baseline's own launch-to-launch spread is
~2% -- FlashInfer selects GEMM/MoE tactics per process, so a single A/B launch pair
cannot separate a few percent from restart noise.
Accepted length is unchanged (5.505 median in both arms) -- these are cycle-time changes,
not acceptance ones.
Every change here is one a production workload sees; nothing in this PR is specific to
the benchmark mode the measurement runs in.
What changed
-249 us per verify cycle in total.
Attribution is from the GPU trace (kernel counts and per-cycle kernel time) plus the
acceptance-length-independent
cycle_us = (elapsed_s - ttft_s) / verify_steps;output_tpsalone has a +-5% spread at this sample count, wider than any single item.torch.argmaxon [6, 129280] fp32 takes 23.6 us --at::native::reduce_kernelgives each row one block, so six very wide rows leave the machine idle for a reduction
that reads 3.1 MB. A flat two-stage split does it in 3.5 us. The accept sits between
the target verify and the KV commit, so the whole cost was exposed.
vision_topkdropped thepacked_outargument the text path passes. It is therouter every MoE layer of a checkpoint with a vision tower goes through, so each layer
paid a
PackTopkIdslaunch to rebuild what the gate already had in registers.standalone
mxfp8_quantizeinside the first projection. At BS=1 the two launches costabout the same.
<<<1, 32>>>kernel whose entirepartition loop runs on thread 0, storing each 32-byte entry to global memory. The loop
is over
num_sm_parts=num_sms / s_q, so a BS=1 step walks 152 partitions at~156 ns each -- 28.05 us in the decode graph, fully exposed. It cannot be hoisted out:
on_after_cuda_graph_warmupclears the scheduler before capture on purpose, becausethe schedule depends on the step's
topk_lengthand a frozen one would leave the tailof a longer row unattended. The new
decoding_sched_metaruns the same algorithm overa whole block -- request state in registers instead of a chain of dependent shared
loads, and the walk stops once the last request is consumed, leaving the whole idle
tail (over 100 of the 152 partitions at BS=1) to be filled in parallel. 28.05 -> 7.44
us in situ, same 80 launches per 20 steps, and no
get_mla_metadata_kernelleft inthe trace.
Correctness
Changes 1, 3 and 4 are bitwise identical to what they replace, with new registered tests:
test/registered/kernel/speculative/test_dspark_fast_argmax.py-- vstorch.argmaxover random rows, exact ties, +-Inf, an all -Inf row and a strided row. Ties resolve to
the lowest index, matching
ArgMaxOps' strict>.test/registered/kernel/hyperconnection/test_hc_combine_norm_mxfp8.py-- vshc_combine_normfollowed byflashinfer_mxfp8_quantize(..., is_sf_swizzled_layout= True)on both itscudaandcute-dslbackends, across five magnitude decades:the BF16 output, the FP8 values and the swizzled UE8M0 scale block all compare equal.
test/registered/kernel/attention/test_decoding_sched_meta.py-- the schedule itselfover batch 1..64, s_q 1 and 6, top-k 512 and 2048 and five
topk_lengthshapesincluding all-zero and all-one rows; the attention output compared as raw bits (a
random fp8 cache decodes to NaN in places); and the extra-cache schedule, which rounds
the main length up to a block before adding the extra one. FlashMLA's
DecodingSchedMetaends in a_padword it never writes, so the comparison covers theseven defined fields.
Change 2 passes the same ids through a second output buffer the gate already fills.
No dataset evaluation was rerun, because nothing here is an approximation.
Measured and deliberately left out
These were built and measured on the same box in the same session and are not in this
PR, recorded so nobody repeats them:
SGLANG_SIMULATE_ACC_LENtake the in-graph accept is worth -104 us/cyclein that mode and nothing at all outside it.
fold_eligiblerefuses the fold wheneverthe variable is set, so a simulated run takes the eager accept while a real greedy run
takes the folded one, and the graph's gated copy runs on top of it -- about 40 tiny
launches, two full-vocabulary argmaxes, three NCCL broadcasts and a second KV-inject
chain, every step. Staging the drawn length in a device buffer fixes it. Left out
because it cannot help a production workload: with the variable at its default the
fold_eligibleexpression is already equivalent, so the change is inert. It is abenchmark-fidelity fix, not a speedup, and belongs on its own if anyone wants it.
The chain is
tiny_gemm_bf162.68 us +moe_fused_gate2.85 us = 5.53 us per layer,and the router chain exposes only 64 us/cycle because 87% of it is already hidden on a
side stream.
tiny_gemm_bf16is not the weak link either -- a split-N Triton GEMV overseven configurations came in at 10.8-13.4 us against its 2.68. So a fusion buys one
launch, ~0.2-0.4% end to end, against a bitwise reimplementation of sqrtsoftplus,
noaux_tc, renormalize and the in-register pack. An earlier Triton prototype was bitwise
correct but 21-45 us: a grid-wide release/acquire dominates at this size.
mega_mhcis a regression at BS=1: 10.7 us at T=6 against the 5.25 us ofcritical path it replaces, because SGLang hides the mHC statistics and Sinkhorn on a
side stream and the fused kernel drags them back onto the main one. +434 us/cycle.
mega_gateis capped at the same 64 us the router chain exposes. Both are worthrevisiting at BS>=64, where side streams have less to hide.
norm-rope-attn-rope-castis at parity at BS=1: 18.05 us ats_q=6 h_q=64 topk=512 against ~18.4 us for sglang's sparse decode +
q_rope_store+the O-RoPE and O-quant it also absorbs. It also does not build under nvcc 13.0 without
widening two non-dependent
static_asserts that sit in discardedif constexprbranches (
core_attn/kernel.cuh:327and:536; at h_q=64,FOLD_FACTORis 2 and bothbranches are dead). That is the same build failure [DeepSeek-V4.1] Bump FlashMLA to the fork's rebase head (v4.1 kernels) #39171 works around by skipping the
TU group.
torch.topkis worthanother -62 us/cycle (
candidate_block_logitsgoes 82.9 -> 8.1 us), but it cannot keepthe
torch.topktie contract thatcandidate_block_logitsdocuments andtest_dsv4_indexer_postprocessenforces: top-k v2 partitions the row, so on exactlytied scores it keeps a different (equally-scoring) set, and it ranks NaN last where
torch.topkranks it first. Left out rather than weakening that test.(6109 vs 6028 us/cycle): FlashInfer's two launches spread a [<=8, 1280] row over more
CTAs than one-CTA-per-row does, and at that shape the parallelism beats the launch and
the round trip the fusion removes.
Unrelated, but worth knowing
lmsysorg/sglang:dev-dsv41shipslibcuda.soonly under/usr/local/cuda/lib64/stubs,while the
sgl_kernel_jit_cuda_ipcJIT link line uses-L/usr/local/cuda/lib64 -lcuda.The link fails with
cannot find -lcuda, SGLang logsSetup Custom allreduce failedandfalls back to NCCL for every TP all-reduce in the model. On this box that is worth
+28% at BS=1 (681.54 -> 872.50 tok/s) and the only symptom is one warning line.
export LIBRARY_PATH=/usr/local/cuda/lib64/stubsfixes it. Worth checking on any boxwhere DSV4.1 BS=1 numbers come in low; I have not touched the image or the link line in
this PR.
Checklist
pre-commit run --from-ref ... --to-ref ...).The
check-registered-testshook is red ondsv4.1for 23 pre-existing files; thethree files added here are not among them.
CI States
Latest PR Test (Base): ❌ Run #34830084240
Latest PR Test (Extra): ❌ Run #34830083868
Latest PR Test (AMD ROCm 10): ❌ Run #34830084204