Skip to content

perf(frozen_kv_mtp): capture the assistant seed step with CUDA graph (+6.8% chat tok/s, +4.4% summ tok/s) - #27

Draft
pyc96 wants to merge 1 commit into
pyc/feat-gemma4-ultimate-v2from
pyc/feat-gemma4-mtp-seed-cuda-graph
Draft

perf(frozen_kv_mtp): capture the assistant seed step with CUDA graph (+6.8% chat tok/s, +4.4% summ tok/s)#27
pyc96 wants to merge 1 commit into
pyc/feat-gemma4-ultimate-v2from
pyc/feat-gemma4-mtp-seed-cuda-graph

Conversation

@pyc96

@pyc96 pyc96 commented May 26, 2026

Copy link
Copy Markdown
Owner

Summary

Captures the assistant seed step in FROZEN_KV_MTP with CUDA graph. Previously only the recurrent draft loop was captured; the seed step (one decode-shape forward of the assistant) ran eager and cost ~20-25 % of decode wall time at speculative_num_steps=3.

Measured: +6.8 % chat tok/s, +4.4 % summ tok/s, ~5 % TPOT reduction on Gemma-4 31B-it MTP H100 TP=2. accept_length unchanged.

What the seed step is and why it was eager

Per scheduler iter, the FROZEN_KV_MTP V1 worker runs (in order):

  1. Target prefill (only on prefill iters) — eager (variable shape)
  2. Assistant seed step (after prefill, then again after every verify) — was EAGER
  3. Recurrent draft loop (speculative_num_steps - 1 iters) — captured by FrozenKVMTPCudaGraphRunner
  4. Target verify — captured by target's standard cuda graph runner

The seed step is one decode-shape forward of the assistant model that seeds topk_p / topk_index / hidden_states on the FrozenKVMTPDraftInput for the recurrent loop. It's structurally identical to one iter of the recurrent loop, but runs through draft_model_runner.forward() which has its own cuda graph disabled (the worker forces disable_cuda_graph=True for the draft TpModelWorker because the recurrent loop captures its own outer graph).

Result: the eager seed forward is launched twice per scheduler iter, dominates ~20-25 % of decode wall time, and is invisible to the existing capture pipeline.

What this PR does

File Change
python/sglang/srt/speculative/frozen_kv_mtp_cuda_graph_runner.py New FrozenKVMTPSeedInputBuffers dataclass (input_ids + hidden_states inputs + topk_p / topk_index / hidden_states outputs). New methods _init_seed_buffers, _capture_seed, _capture_one_seed_graph, can_run_seed, replay_seed. Captures at the same batch sizes as the recurrent loop (29 sizes on the campaign workload). Falls back to eager seed silently if capture fails. Env var SGLANG_FROZEN_KV_MTP_DISABLE_SEED_CG=1 skips capture for A/B benchmarking.
python/sglang/srt/speculative/frozen_kv_mtp_worker.py _run_assistant_seed_step uses replay_seed when can_run_seed(bs) AND no multimodal embeds AND no mrope_positions. Otherwise the eager path runs as before. Stitches seed graph outputs (topk_p, topk_index, hidden_states) onto the new FrozenKVMTPDraftInput.

The two paths are predicate-disjoint, so the eager fallback is 100 % unchanged for any caller that doesn't qualify.

Measured

google/gemma-4-31B-it, H100 TP=2, triton, FROZEN_KV_MTP NEXTN 3/4/1, 80 prompts, warmup 2, seed 1, --disable-overlap-schedule:

Metric seed CG OFF (baseline) seed CG ON (this PR) Δ
chat 1k/1k tok/s 1466 1566 +6.8 %
chat 1k/1k medTPOT (ms) 34.0 32.1 −5.7 %
chat 1k/1k medTTFT (ms) 2829 2778 −1.8 %
summ 8k/1k tok/s 428 447 +4.4 %
summ 8k/1k medTPOT (ms) 24.8 23.8 −4.0 %
summ 8k/1k medTTFT (ms) 80956 77624 −4.1 %
chat accept_length 3.135 3.128 tied (same draft)
summ accept_length 3.142 3.139 tied (same draft)

Correctness — per-prompt parity (20 greedy prompts, temp=0)

{"total": 20, "matched": 19, "mismatched": 1, "match_rate": 0.95}

The single mismatch is a long output that diverges after ~50 tokens (semantically equivalent; bf16 numerical drift from fp32 accumulation differences inside the captured kernel — same drift pattern documented in PR #19 and #20).

Server log evidence (init)

[info TP0] Capture Frozen-KV MTP draft cuda graph begin.
[info TP0] Capture Frozen-KV MTP seed cuda graph begin.
[info TP0] Capture Frozen-KV MTP seed cuda graph end (captured 29 batch sizes).
[info TP0] Capture Frozen-KV MTP draft cuda graph end.

Reproducer

# Server A: seed CG ON (this PR)
CUDA_VISIBLE_DEVICES=0,1 python -m sglang.launch_server \
  --model-path google/gemma-4-31B-it --dtype bfloat16 --trust-remote-code --tp-size 2 \
  --port 30000 --attention-backend triton \
  --speculative-algorithm NEXTN \
  --speculative-draft-model-path google/gemma-4-31B-it-assistant \
  --speculative-num-steps 3 --speculative-num-draft-tokens 4 --speculative-eagle-topk 1 \
  --max-running-requests 80 --disable-overlap-schedule

# Server B: seed CG OFF (eager seed path, pre-PR behavior)
SGLANG_FROZEN_KV_MTP_DISABLE_SEED_CG=1 CUDA_VISIBLE_DEVICES=2,3 python -m sglang.launch_server \
  ... --port 30001 ...

# Bench
python -m sglang.bench_serving --backend sglang-oai-chat \
  --host 127.0.0.1 --port 30000 --model google/gemma-4-31B-it \
  --dataset-name random --random-input-len 1000 --random-output-len 1000 \
  --num-prompts 80 --warmup-requests 2 --seed 1

Stack base

pyc/feat-gemma4-ultimate-v2 (PR #21)


CI States

Latest PR Test (Base): ❌ Missing run-ci label -- add it to run CI tests.
Latest PR Test (Extra): ❌ Blocked -- run-ci is required first.

The recurrent draft loop has been captured by FrozenKVMTPCudaGraphRunner
since the FROZEN_KV_MTP V1 worker was introduced, but the assistant
seed step (a single decode-shape forward of the assistant after target
prefill and after each verify) ran EAGER.  That's roughly 20-25 % of
decode wall time on speculative_num_steps=3 because the seed step
happens twice per scheduler iter (after prefill once, after every
verify) and it's the same shape as one captured loop iteration.

This PR captures the seed step as a separate CUDA graph per supported
batch size (29 sizes on the campaign workload).  At replay time:

* draft_model_runner.forward runs the captured graph
* topk_p / topk_index / hidden_states are written to static output
  buffers
* the worker copies the outputs into the new FrozenKVMTPDraftInput
  so the next iter's recurrent loop reads them via spec_info.*

Implementation:

* python/sglang/srt/speculative/frozen_kv_mtp_cuda_graph_runner.py:
  - New FrozenKVMTPSeedInputBuffers dataclass (input_ids, hidden_states,
    positions, seq_lens + req_pool_indices + output buffers for
    topk_p / topk_index / hidden_states).
  - New methods: _init_seed_buffers, _capture_seed,
    _capture_one_seed_graph, can_run_seed, replay_seed.
  - Capture happens in __init__ AFTER the recurrent loop graphs are
    captured.  Catches Exception (not just RuntimeError) so any
    failure cleanly falls back to eager seed without blocking server
    startup.
  - Env var SGLANG_FROZEN_KV_MTP_DISABLE_SEED_CG=1 skips seed capture
    (for A/B benchmarking).

* python/sglang/srt/speculative/frozen_kv_mtp_worker.py:
  - _run_assistant_seed_step uses replay_seed when can_run_seed(bs)
    AND no multimodal embeds AND no mrope_positions.  Stitches the
    seed graph outputs onto the FrozenKVMTPDraftInput (topk_p,
    topk_index, hidden_states) instead of running the eager forward.
  - Eager fallback path preserved (still runs when seed graph
    unavailable, fast-path predicates fail, or env var is set).

Validation (google/gemma-4-31B-it, H100 TP=2, triton, FROZEN_KV_MTP
NEXTN 3/4/1, 80 prompts, warmup 2, seed 1, --disable-overlap-schedule):

  Metric            | seed CG OFF  | seed CG ON   | delta
  ------------------|-------------:|-------------:|----------
  chat tok/s        |  1466        | **1566**     | **+6.8 %**
  chat medTPOT      |    34.0 ms   | **32.1 ms**  | **-5.7 %**
  chat medTTFT      |  2829 ms     | **2778 ms**  | **-1.8 %**
  summ tok/s        |   428        | **447**      | **+4.4 %**
  summ medTPOT      |    24.8 ms   | **23.8 ms**  | **-4.0 %**
  summ medTTFT      | 80956 ms     | **77624 ms** | **-4.1 %**
  chat accept_len   |    3.135     |    3.128     | tied
  summ accept_len   |    3.142     |    3.139     | tied

Per-prompt parity (20 greedy prompts, seed CG ON vs OFF):
  match_rate = 19/20 = 0.95
  (single mismatch in a long output, semantically equivalent;
   bf16 numerical drift from fp32 accumulation differences inside
   the captured kernel)

Server log on init (visible to ops):
  [info] Capture Frozen-KV MTP draft cuda graph begin.
  [info] Capture Frozen-KV MTP seed cuda graph begin.
  [info] Capture Frozen-KV MTP seed cuda graph end (captured 29 batch sizes).
  [info] Capture Frozen-KV MTP draft cuda graph end.

Stack base: pyc/feat-gemma4-ultimate-v2 (PR #21)
Co-authored-by: Claude
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