Skip to content

[DSV4.1] Combine DSpark decode and prefill kernel optimizations - #39370

Merged
BBuf merged 22 commits into
sgl-project:dsv4.1from
BBuf:bbuf/dsv41-combined-optimizations
Sep 15, 2026
Merged

BBuf merged 22 commits into
sgl-project:dsv4.1from
BBuf:bbuf/dsv41-combined-optimizations

Conversation

@BBuf

@BBuf BBuf commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

DeepSeek-V4.1 Flash spends substantial decode time in communication, small normalization/quantization launches and speculative metadata handling. This combines #39301/#39336 with the current dsv4.1 improvements and automatically selects supported paths. Current head: 9623e9040af55bc2fdd2a05fddae362011be4359, merged with upstream 1e9679db59ea7cf72a3ce2f2a547bc6e54c09ebc.

  • Remove the custom small-batch paged attention kernel, its dedicated tests, backend dispatch and fused inverse-RoPE plumbing. Attention uses the upstream backend and the model applies the ordinary inverse RoPE. Earlier small-attention throughput/accuracy results do not describe this final implementation.
  • Retain compact greedy argmax transport over NVLink, model-specific metadata/KV handling, small-row all-reduce/mHC/norm/quant fusion, Q RMSNorm + MXFP8 quantization and prefill improvements. Preserve the separate wo_a projection/quantization path and required BF16 intermediate rounding.
  • Preserve large-row compensated mHC, including TF32 high/residual and BF16x3 paths, original FP32 weights, invariant/unsupported fallbacks and weight-update guards. Respect the existing SGLANG_OPT_DEEPGEMM_HC_PRENORM switch.
  • Preserve upstream [DSV4.1] Record side-stream work right before its join to keep CUDA-graph replay on one stream #39420 side-stream recording order: record deferred mHC statistics after the sublayer and immediately before their join, including fused communication consumers; routed pre-quantization remains after router/top-k. Integrate the latest upstream metadata-buffer and compressed-pool fixes. No new optimization environment-variable definitions are added relative to 8bd60a9913.

Latest upstream merge smoke check (9623e9040a)

Resolved the relocated communication-test runner conflict using the upstream B200 runner and kept the upstream deletion of the development-only bench_c2_verify.py. The mHC graph cases remain intact. No subsequent optimization experiments are included.

On 4×GB300, with the upstream-required sglang-kernel 0.4.7 and sgl-deep-gemm 0.2.0 in an isolated environment:

  • All 96 mHC epilogue CUDA-graph test cases passed on all four TP ranks.
  • The TP4/EP1 DSpark server started and completed a 4096-token input / 128-token output request.
  • A batch of 64 requests completed with 64 output tokens each; the server log confirms 64 concurrent decode requests with CUDA Graph enabled.
  • Acceptance simulation was disabled for both generation checks. This is a functional smoke check; throughput benchmarks and full accuracy suites were not rerun on this merge commit.

Previous 4×GB300 validation (7322accddb)

Same checkpoint revision dba1be0a40aa45a94ad051997016db3960a90277, torch 2.13.0+cu130, Triton 3.7.1, FlashInfer 0.6.18, sglang-kernel 0.4.6.post1, sgl-deep-gemm 0.1.7, Transformers 5.12.1 and driver 580.105.08. No dependency overlay. Each arm uses a clean immutable checkout and a fresh server; GPU jobs are serial.

Workload Upstream 8bd60a9913 Final 7322accddb
BS1 median output tps 901.33 1070.80
C64 output tps 5659.61 7621.19
C64 median TTFT (ms) 3832.24 2677.61
C64 completed requests 1024/1024 1024/1024

Final BS1 starts have medians 1068.93 and 1072.48; all 12 measurements range 1051.52–1099.00. The upstream BS1 control has one fresh start with six measurements. Relative to this fresh upstream control, the complete PR improves observed C64 throughput by 34.66%. Earlier c495 C64 throughput was 7626.35 on the same node; that is historical evidence and is not substituted for the fresh upstream comparison.

BS1: TP4/EP1, static DSpark block 5, fixed 4096 input/1024 output, simulated acceptance 5.5, temperature 0, ignore_eos; one warmup and six measured requests per start. Output tps excludes prefill and is timed from the first to last output event. Two independent final starts are pooled with every sample retained. C64: TP4/EP4, simulated acceptance 5.4, frozen 4096/1024 corpus, 64 warmups and 1024 measured requests, same pinned client. These are simulated-acceptance performance measurements, not real-acceptance throughput.

Profiled workload Upstream kernel streams / including copies Final kernel streams / including copies Target-graph kernel streams (both)
BS1, 4K input, TP4/EP1 11 / 12 11 / 12 7
BS1, 64K input, TP4/EP1 11 / 12 11 / 12 7
C64, 4K input, TP4/EP4 9 / 10 9 / 10 5

The 40 attention-combine kernels use two streams at BS1 (first layer, then the remaining 39 layers), and a single stream at C64. The same pattern appears upstream. The final C64 target graph contains 160 compensated TF32 projection calls and 80 Sinkhorn calls per replay on one statistics stream, confirming that large-row compensated mHC remains active. The final 64K check exercises the filtered graph beyond the 16K candidate budget (target graph ID 41, versus ID 38 at 4K); upstream uses the same full-filter graph at both lengths. No per-layer stream proliferation is observed in these traces.

Stream counts come from original Torch profiler args.stream, without track remapping. BS1/4K has both 20-step GPU and 5-step CPU+GPU captures; BS1/64K has a 5-step CPU+GPU capture; C64 captures 5 steps only after the scheduler reports 64 requests decoding together, with CPU annotations confirming the batch size. Profiling follows and is separate from throughput timing. Counts cover all four TP ranks and distinguish all decode kernels from the 40-layer target graph.

Correctness checks: changed-file pre-commit passed; 122 selected metadata/pool, wo_a and compensated-mHC regression tests passed. Three existing RoPE-policy mock cases fail on both final and unmodified upstream because the fixture lacks q_head_norm; these are not reported as passing. No production workaround was added.

Real-acceptance functional check: final and upstream each score 64/64 on the same held-out five-shot GSM8K samples, concurrency 1, temperature 0, seed 0, output cap 4096 and simulation disabled. This is a smoke check, not a full accuracy comparison. Full GSM8K/AIME/GPQA were not rerun for this cleanup; earlier results are tied to their tested commits. Removal of the experimental attention path is not presented as proof of accuracy parity across all retained optimizations. Hosted CI is separate from these dedicated-node checks.


CI States

Latest PR Test (Base): 🚫 Run #34941353724
Latest PR Test (Extra): ❌ Run #34941353254
Latest PR Test (AMD ROCm 10): ❌ Run #34941353597

BBuf added 12 commits September 14, 2026 13:15
`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.
`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.
…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.)
Split FP32 projection weights into TF32 high and residual components, then fuse per-slice compensation into Sinkhorn. Preserve small-row and batch-invariant paths and reject online updates while derived graph weights are active.

Validated on GB300: 35 coefficient, replay, dispatch, epilogue and update-guard tests; 1314 real-acceptance GSM8K requests, 1277 correct, no request errors and two length-limited outputs.
@BBuf BBuf added the run-ci label Sep 14, 2026
@BBuf

BBuf commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Server launch commands used for the new AL=5.4, random 4K/1K, concurrency 1–64 curve in this PR. These are the recorded commands, with the original local paths retained. Run the two engines sequentially on the same four GPUs, using their separate environments.

Versions and paths

  • Hardware: one dedicated 4×GB300 node; driver 595.71.05.
  • Measured SGLang commit: f14579f0320ebbded7ab788418bdaca75ad5006a. PR head b702fa4b0f516ca161463cad5613a4cf6db9d1a2 has identical serving/kernel implementation; the subsequent changes affect only four test files (entry point / CI registration).
  • Model: deepseek-ai/DeepSeek-V4.1-Flash, revision dba1be0a40aa45a94ad051997016db3960a90277, downloaded to /scratch/dsv41/checkpoint.
  • vLLM packages: from vllm/vllm-openai:deepseekv41-flash-0909, arm64 digest sha256:d84a123255b822fc22508635218000187221794f59c0694c33b0650d1e377d58, used through the isolated vllm-venv below. This executable is necessary to select the tested vLLM environment, rather than the SGLang environment's Python.
  • Replace the local model/source/venv paths when reproducing on another machine. LIBRARY_PATH supplies the CUDA driver stubs for JIT linking, not a replacement runtime driver.

SGLang

export MODEL_PATH=/scratch/dsv41/checkpoint
export CUDA_VISIBLE_DEVICES=0,1,2,3
export MAX_JOBS=16
export LIBRARY_PATH=/usr/local/cuda/lib64/stubs${LIBRARY_PATH:+:$LIBRARY_PATH}
export PYTHONPATH=/scratch/dsv41-combined-al54/sglang/python

export SGLANG_RAGGED_VERIFY_MODE=static
export SGLANG_SIMULATE_ACC_LEN=5.4
export SGLANG_SIMULATE_ACC_METHOD=match-expected

python3 -m sglang.launch_server \
  --trust-remote-code \
  --model-path "$MODEL_PATH" \
  --served-model-name deepseek-ai/DeepSeek-V4.1-Flash \
  --tp 4 --ep-size 4 \
  --mem-fraction-static 0.8 \
  --speculative-algorithm DSPARK \
  --speculative-dspark-block-size 5 \
  --cuda-graph-max-bs-decode 64 \
  --reasoning-parser auto \
  --tool-call-parser auto \
  --host 127.0.0.1 --port 31001

This is attention TP4 / MoE EP4, with compensated mHC enabled. No DPA flags or scheduling overrides were added. Both starts resolved to:

Setting Effective value
chunked_prefill_size 16384
max_prefill_tokens 16384
max_running_requests 256
prefill_decode_interval 0
dp_size 1
enable_dp_attention false
enable_mixed_chunk false

The provided command did not fix the server seed. SGLang generated 9181494 and 281704691 for the two starts; the client seed was fixed to 42.

vLLM

Run this after stopping the SGLang server, in the separate vLLM environment:

export MODEL_PATH=/scratch/dsv41/checkpoint
export CUDA_VISIBLE_DEVICES=0,1,2,3
export MAX_JOBS=16
export LIBRARY_PATH=/usr/local/cuda/lib64/stubs${LIBRARY_PATH:+:$LIBRARY_PATH}
unset PYTHONPATH

export VLLM_ENGINE_READY_TIMEOUT_S=7200
export VLLM_SERVER_DEV_MODE=1
export VLLM_USE_RUST_FRONTEND=1

/scratch/dsv41-combined-al54/vllm-venv/bin/python \
  -m vllm.entrypoints.cli.main serve "$MODEL_PATH" \
  --served-model-name deepseek-ai/DeepSeek-V4.1-Flash \
  --tokenizer-mode deepseek_v41 \
  --tensor-parallel-size 4 \
  --tool-call-parser deepseek_v41 \
  --enable-auto-tool-choice \
  --reasoning-parser deepseek_v41 \
  --mm-encoder-tp-mode data \
  --speculative-config '{"method":"dspark","num_speculative_tokens":5,"draft_sample_method":"probabilistic","rejection_sample_method":"synthetic","synthetic_acceptance_length":5.4,"enable_adaptive_verification":false}' \
  --host 127.0.0.1 --port 31002

This retains the supplied vLLM command's attention TP4 / MoE TP4 topology (enable_expert_parallel=False). Its server seed was the default 0 on both starts. The different MoE topology is part of the supplied deployment comparison; the curve does not isolate kernel changes from all other framework/configuration differences.

Curve measurement protocol

  • Same pinned SGLang v0.5.19 bench_serving client for both engines, commit 0bcd822377da7b5718e674eaf9c870d349424dd1; backends sglang-oai and vllm, respectively.
  • Random input 4096 tokens / output 1024 tokens, range ratio 1, temperature=0, ignore_eos=true, client seed 42, streaming completions, request rate inf.
  • Same frozen input corpus for both engines, SHA256 1f8e67cba16f9fa16573286775db9a2353fc4c42006695ba63c3515c7b2401f3. The client wrapper freezes dataset loading and records additional per-request timings; request scheduling, transport, warmup, cache flushing and metric calculations remain in the pinned client.
  • Each point: 64 warmup requests, then cache flush, then max(128, 16 × concurrency) measured requests. Concurrencies: 1, 2, 4, 8, 16, 32, 64; execution order in each start: 64, 1, 2, 4, 8, 16, 32.
  • Two independent server starts per engine; plotted points are medians of the two start-level measurements. All 9,216 measured requests succeeded, each with 1,024 output tokens.
  • X axis: 1000 / median TPOT(ms) in tokens/s/user. Y axis: total output throughput divided by 4, in tokens/s/GPU.

At c=64, the two-start median total output throughput is 7487.70 vs 6567.17 tokens/s (+14.02% for SGLang), while median TTFT is 2663.57 vs 667.56 ms. The throughput curve should not be interpreted as TTFT parity. These commands use synthetic acceptance for performance measurement.

@BBuf

BBuf commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

GSM8K and AIME accuracy comparison for the restored compensated-mHC head

Tested current PR head f0f2d12b5ec4896c153c28117a86d364b21b4251 against PR base 5d42bf1f12a9e0c675be316693058d03e3df8cf2. These are fresh evaluations with real speculative acceptance. GSM8K has the same or a slightly higher aggregate score; AIME has lower measured scores, so this is not an accuracy-parity claim.

Evaluation Base correct / total Restored head correct / total Change (percentage points) Output-limit hits, base → head
GSM8K, concurrency 1 1278/1314 (97.260%) 1278/1314 (97.260%) 0.000 0 → 1
GSM8K, concurrency 64 1273/1314 (96.880%) 1277/1314 (97.184%) +0.304 2 → 1
AIME 2026, serial, temperature 0 30/30 (100.000%) 29/30 (96.667%) −3.333 0 → 1
AIME 2026, 16 repetitions, concurrency 64, temperature 1 465/480 (96.875%) 462/480 (96.250%) −0.625 11 → 14

All four evaluations completed on both revisions with zero request errors. All samples, including output-limit hits and empty answers, remain in the denominators.

Observed differences:

  • GSM8K concurrency 1 has 6 previously wrong answers become correct and 6 previously correct answers become wrong. Concurrency 64 has 9 become correct and 5 become wrong. Equal or nearby aggregate scores do not imply identical per-example outputs.
  • The serial AIME difference is aime26-14: the base returned the correct answer in 57,120 output tokens; the restored head reached 65,536 tokens without a final answer. This counts as a failure in the published score.
  • In the 16-repeat AIME run, the largest per-question difference is also aime26-14: 7/16 correct on the base versus 3/16 on the head, with 7 versus 11 output-limit hits. Across all 480 requests, each revision has four incorrect answers that did not hit the limit; the remaining failures hit the limit. These are independent temperature-1 samples, not paired random draws, and this observation does not establish the cause of the difference.
  • This compares the whole PR against its base. It does not isolate the effect of restoring compensated mHC. A predeclared follow-up will replay this one AIME question on the base, cleanup 90f348817e, and restored head at 64K and 128K budgets. Those diagnostic outcomes will not replace the primary scores above.

Reproduction conditions:

  • Same dedicated 4×GB300 node, driver 580.105.08, same DeepSeek-V4.1-Flash checkpoint revision dba1be0a40aa45a94ad051997016db3960a90277.
  • Identical TP4/EP1 serving parameters, flashinfer_mxfp4 MoE runner, static DSPARK block size 5. SGLANG_SIMULATE_ACC_LEN and SGLANG_SIMULATE_ACC_METHOD were unset, verified in the actual server process environment. No new optimization flag or source overlay was used on the restored head.
  • Fixed dependencies: torch 2.13.0+cu130, FlashInfer 0.6.18, sglang-kernel 0.4.6.post1, sgl-deep-gemm 0.1.7, Triton 3.7.1, Transformers 5.12.1. The installed DeepGEMM supplies the mHC TF32 API; sparse candidate APIs are absent, so that separate path uses its automatic Torch fallback.
  • GSM8K uses the same 5-shot prompt and 1,314 held-out test examples. The first five test records supply demonstrations and are excluded from scoring. Temperature 0, seed 0, top-p 1, maximum 4,096 output tokens.
  • AIME 2026 uses all 30 questions, thinking enabled, reasoning effort max, top-p 0.95, maximum 65,536 output tokens. The serial pass uses temperature 0/seed 0. The repeated pass submits 16 repetitions per question through one 64-thread pool, temperature 1, without a request seed; the score is mean correctness over all 480 samples.
  • AIME evaluator: sgl_eval 0.1.0, vendored NeMo evaluator revision 645cf567ff08c0ae9cc3fc8e1edbb975b3067816. Frozen input/prompt/scorer hashes and generation settings match across revisions; sample IDs, counts and aggregate scores were audited against the saved per-request results.

GPQA Diamond comparison is still running and will be reported separately. These accuracy measurements are separate from the PR's synthetic-acceptance throughput results.

…v4.1

Integrate NVLink vocabulary gather and ratio-1/2 stream scheduling.
Use compact greedy collectives and model-specific metadata plans.
Compute only the 16 consumed attention heads for small SM10x batches,
fuse inverse RoPE and Q normalization/quantization, and preserve the
large-batch compensated mHC path and deterministic fallbacks.
@BBuf
BBuf requested a review from fzyzcjy as a code owner September 14, 2026 21:04
@BBuf

BBuf commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator Author

Reproducing the 1070.80 output tokens/s BS=1 result after removing small-batch attention

This result is tied to 7322accddbc1d302213342c97d1b516fda209af1, which includes upstream dsv4.1 commit 8bd60a99133943eb2caecec2ba24f816336bbd33. It uses upstream attention with ordinary inverse RoPE; the removed custom small-batch attention implementation is not part of this measurement.

The metric is decode/output throughput at simulated speculative acceptance 5.5, excluding prefill. It is not real-acceptance throughput or an accuracy result. The previous concurrency-curve comment uses AL=5.4 and EP4; those are different measurement settings.

Tested environment

  • Dedicated 4×NVIDIA GB300, TP4/EP1, no competing GPU jobs; driver 580.105.08. Recorded pre-launch SM clock: 2070 MHz; power limit: 1400 W/GPU (recorded state, not a command to change power settings).
  • Model: deepseek-ai/DeepSeek-V4.1-Flash, revision dba1be0a40aa45a94ad051997016db3960a90277, available locally at /scratch/dsv41/checkpoint.
  • torch 2.13.0+cu130, Triton 3.7.1, FlashInfer 0.6.18, sglang-kernel 0.4.6.post1, sgl-deep-gemm 0.1.7, Transformers 5.12.1.
  • No DeepGEMM dependency overlay or experimental source overlay. The installed sparse-candidate API is absent, so that separate path selects its automatic Torch fallback. No CPU-affinity override.

From an existing SGLang git clone, create an isolated checkout:

git fetch https://github.com/BBuf/sglang.git bbuf/dsv41-combined-optimizations
git worktree add --detach ../sglang-pr39370-1070 7322accddbc1d302213342c97d1b516fda209af1
cd ../sglang-pr39370-1070
git rev-parse HEAD

Use the pinned runtime above and a clean shell without leftover SGLANG_* / DSV41_* experiment overrides or Python/library overlays. The original harness cleared those overrides. Replace only the checkpoint path if necessary.

Server command

The original outer harness supplied SGLANG_SPEC_TP_SYNC=rng; it is included explicitly below so it is not lost when copying the inner launch script. This is an existing upstream setting, not a new environment variable introduced by this PR.

#!/usr/bin/env bash
set -euo pipefail
export MODEL_PATH=/scratch/dsv41/checkpoint
export SGLANG_SPEC_TP_SYNC=rng
export SGLANG_RAGGED_VERIFY_MODE=static
export SGLANG_SIMULATE_ACC_LEN=5.5
export SGLANG_SIMULATE_ACC_METHOD=match-expected
CUDA_VISIBLE_DEVICES=0,1,2,3 PYTHONPATH="$PWD/python" MAX_JOBS=16 \
python -m sglang.launch_server \
  --model-path "$MODEL_PATH" \
  --served-model-name deepseek-ai/DeepSeek-V4.1-Flash \
  --tp 4 --ep-size 1 --trust-remote-code \
  --moe-a2a-backend none --moe-runner-backend flashinfer_mxfp4 \
  --mem-fraction-static 0.80 --max-total-tokens 33554432 \
  --chunked-prefill-size 4096 \
  --cuda-graph-bs-decode 1 2 4 8 16 32 64 \
  --max-running-requests 128 \
  --speculative-algorithm DSPARK --speculative-dspark-block-size 5 \
  --skip-server-warmup --reasoning-parser deepseek-v41 \
  --random-seed 42 --decode-log-interval 10 \
  --host 127.0.0.1 --port 30021

Exact workload and timing client

One fixed synthetic 4096-token input, 1024 output tokens, sequential requests (concurrency 1), temperature=0, ignore_eos=true, stream_interval=1. The same input is reused; cache is flushed before every request and the client asserts cached_tokens == 0. The client calls /freeze_gc after readiness, as in the recorded run.

The following generator reconstructs the exact recorded input IDs. The three ranges are the pinned tokenizer's sorted ordinary vocabulary IDs after excluding declared special tokens (128,051 IDs). The SHA256 assertion verifies the generated sequence; it does not depend on another machine's tokenizer version.

Run in a second terminal, in an empty directory for the reproduction files:

cat > make_prompt.py <<'PYGEN'
import hashlib
import json
import random
from pathlib import Path

vocab = list(range(3, 128000)) + list(range(128799, 128846)) + list(range(129264, 129271))
assert len(vocab) == 128051
rng = random.Random(42)
ids = [vocab[rng.randrange(len(vocab))] for _ in range(4096)]
digest = hashlib.sha256(json.dumps(ids, separators=(',', ':')).encode()).hexdigest()
assert digest == 'c37b49585df8fb22e125ae467003a00fff94a3f010cc7cddc03a3b463d047614'
Path('prompt.json').write_text(json.dumps({
    'input_ids': ids, 'input_ids_sha256': digest,
    'max_new_tokens': 1024, 'ignore_eos': True,
}, indent=2) + '\n')
PYGEN
python3 make_prompt.py
Save this as benchmark.py — the recorded timing client, included in full
"""Synthetic random-token BS=1 timing; DSpark target acceptance is simulated at 5.5."""
import argparse
import collections
import hashlib
import json
import statistics
import time
from pathlib import Path

import requests

def sha(data):
    return hashlib.sha256(data).hexdigest()

def save(path,value):
    path.write_text(json.dumps(value,ensure_ascii=False,indent=2)+'\n')

class Client:
    def __init__(self,url):self.url=url.rstrip('/')
    def post(self,route,body=None):
        r=requests.post(self.url+route,json=body or {},timeout=(30,1800));r.raise_for_status();return r
    def wait(self):
        end=time.monotonic()+2400
        while time.monotonic()<end:
            try:
                if requests.get(self.url+'/v1/models',timeout=3).status_code==200:break
            except requests.RequestException:pass
            time.sleep(3)
        else:raise TimeoutError('Server not ready')
        self.post('/freeze_gc')
    def run(self,prompt,max_tokens=2048):
        self.post('/flush_cache?timeout=30')
        body={'input_ids':prompt['input_ids'],'sampling_params':{
            'temperature':0,'max_new_tokens':max_tokens,'ignore_eos':prompt.get('ignore_eos',False),'stream_interval':1},
            'stream':True}
        start=time.perf_counter();first=None;first_count=None;last=None;last_time=None
        with requests.post(self.url+'/generate',json=body,stream=True,timeout=(30,1800)) as r:
            r.raise_for_status()
            for line in r.iter_lines():
                if not line.startswith(b'data: '):continue
                if line[6:]==b'[DONE]':break
                chunk=json.loads(line[6:]);now=time.perf_counter()
                if 'error' in chunk:raise RuntimeError(chunk)
                count=chunk.get('meta_info',{}).get('completion_tokens',0)
                if count and first is None:first=now;first_count=count
                last=chunk;last_time=now
        assert first is not None and last is not None
        meta=last['meta_info'];count=meta['completion_tokens']
        assert meta['prompt_tokens']==len(prompt['input_ids'])
        assert meta.get('cached_tokens',0)==0,meta
        if prompt.get('ignore_eos',False):assert count==max_tokens,meta
        text=last.get('text','')
        words=text.split();ngrams=collections.Counter(tuple(words[i:i+16]) for i in range(max(0,len(words)-15)))
        return {'input_tokens':meta['prompt_tokens'],'output_tokens':count,
            'accept_length':meta.get('spec_accept_length'), 'verify_steps':meta.get('spec_verify_ct'),
            'ttft_s':first-start,'elapsed_s':last_time-start,'first_event_tokens':first_count,
            'output_tps':(count-first_count)/(last_time-first) if last_time>first else None,
            'finish_reason':meta.get('finish_reason'),'max_repeated_16gram':max(ngrams.values(),default=0),
            'request':body,'response':last}

def main():
    p=argparse.ArgumentParser()
    p.add_argument('mode', choices=['bench'])
    p.add_argument('--url', default='http://127.0.0.1:30021')
    p.add_argument('--prompt', type=Path, default=Path(__file__).with_name('prompt.json'))
    p.add_argument('--out', type=Path, required=True)
    p.add_argument('--repeat', type=int, default=6)
    p.add_argument('--max-tokens', type=int, default=None,
                   help='Override the output length saved in the prompt JSON.')
    a=p.parse_args()
    if a.repeat < 1: p.error('--repeat must be positive')
    a.out.mkdir(parents=True, exist_ok=True)
    prompt=json.loads(a.prompt.read_text());values=[]
    max_tokens=a.max_tokens if a.max_tokens is not None else prompt.get('max_new_tokens',1024)
    if max_tokens < 1: p.error('--max-tokens must be positive')
    assert sha(json.dumps(prompt['input_ids'],separators=(',',':')).encode())==prompt['input_ids_sha256']
    c=Client(a.url);c.wait()
    for rep in range(a.repeat+1):
        r=c.run(prompt,max_tokens);r.update(repeat=rep,warmup=rep==0)
        values.append(r);save(a.out/'measurements.json',values)
        print('BENCH',rep,'tps',r['output_tps'],'accept',r['accept_length'],'output',r['output_tokens'],flush=True)
    measured=values[1:]
    summary={'runs':a.repeat,'output_tps_median':statistics.median(r['output_tps'] for r in measured),
      'output_tps_min':min(r['output_tps'] for r in measured),'output_tps_max':max(r['output_tps'] for r in measured),
      'accept_length_median':statistics.median(r['accept_length'] for r in measured) if measured[0]['accept_length'] else None,
      'input_tokens':len(prompt['input_ids']),'output_tokens':[r['output_tokens'] for r in measured],
      'requested_output_tokens':max_tokens,'ignore_eos':prompt.get('ignore_eos',False),
      'accept_length_min':min(r['accept_length'] for r in measured) if measured[0]['accept_length'] else None,
      'accept_length_max':max(r['accept_length'] for r in measured) if measured[0]['accept_length'] else None,
      'prompt_file_sha256':sha(a.prompt.read_bytes()),'acceptance_mode':'Read server launch manifest: simulated target 5.5 for DSpark, not applicable for ordinary decode'}
    save(a.out/'summary.json',summary);print('BENCH_DONE',json.dumps(summary),flush=True)

if __name__=='__main__':main()
python3 benchmark.py bench   --url http://127.0.0.1:30021   --prompt prompt.json --max-tokens 1024 --repeat 6 --out run-a

The client executes one warmup, then six measured requests. It stores every request in measurements.json and the six-measurement summary in summary.json. Stop this server, launch a fresh server with the identical command, then repeat:

python3 benchmark.py bench   --url http://127.0.0.1:30021   --prompt prompt.json --max-tokens 1024 --repeat 6 --out run-b

python3 - <<'PYRESULT'
import json
import statistics
from pathlib import Path
values = [r['output_tps'] for name in ('run-a', 'run-b')
          for r in json.loads((Path(name) / 'measurements.json').read_text())
          if not r['warmup']]
assert len(values) == 12
print('pooled median output tps:', statistics.median(values))
print('range:', min(values), max(values))
PYRESULT

For each request, the calculation is:

output_tps = (final_completion_tokens - first_output_event_completion_tokens)
             / (last_output_event_time - first_output_event_time)

This excludes both prefill time and the tokens already delivered in the first event. It is not 1024 / total_request_latency, and the final statistic is the median of all 12 individual measured requests, not the maximum or the mean of the two startup medians. Profiling is performed separately, after throughput measurement.

Recorded results

Fresh server start Six measured output TPS values Median
A 1079.68458, 1086.06226, 1051.52411, 1071.60884, 1054.21662, 1066.25700 1068.93292
B 1069.98484, 1074.96679, 1089.61730, 1099.00307, 1051.70745, 1058.50979 1072.47582

Pooled median: 1070.79684 output tokens/s (1070.80 rounded). All 12 measured requests produced exactly 1024 output tokens; range 1051.52–1099.00, observed acceptance-length median 5.505376. Startup medians were 1068.93 and 1072.48. This describes the recorded distribution, not a guarantee that every request exceeds 1070 TPS.

The input generator above was checked byte-for-byte at the token-ID sequence level against the frozen input. The timing client is the original client (SHA256 8a9276345df9fa7ae71c3f11f73fb59ac02ffcbc82a47d6b25455346ece89952); this comment documents the existing measurements and does not claim a new benchmark run.

@BBuf
BBuf merged commit 01d34d7 into sgl-project:dsv4.1 Sep 15, 2026
71 of 93 checks passed
@BBuf
BBuf deleted the bbuf/dsv41-combined-optimizations branch September 15, 2026 07:51
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