Skip to content

feat(spec-v2): Option-alpha scaffolding -- FROZEN_KV_MTP via EAGLE V2 (experimental, env-gated, NOT yet functional) - #26

Draft
pyc96 wants to merge 1 commit into
pyc/feat-gemma4-ultimate-v2from
pyc/feat-gemma4-mtp-via-eagle
Draft

feat(spec-v2): Option-alpha scaffolding -- FROZEN_KV_MTP via EAGLE V2 (experimental, env-gated, NOT yet functional)#26
pyc96 wants to merge 1 commit into
pyc/feat-gemma4-ultimate-v2from
pyc/feat-gemma4-mtp-via-eagle

Conversation

@pyc96

@pyc96 pyc96 commented May 26, 2026

Copy link
Copy Markdown
Owner

Summary

Scaffolding for Option-α from the vLLM-vs-SGLang MTP comparison
(agent-pod/runs/20260525_mtp_v2/analysis/vllm_vs_sglang_mtp.md):
route FROZEN_KV_MTP to a thin subclass of EAGLEWorkerV2 instead
of the 787-LOC FrozenKVMTPWorker, mirroring vLLM's architectural
pattern (vllm/v1/spec_decode/gemma4.py:31-340 — also 335 LOC).

Behavior is unchanged by default: FROZEN_KV_MTP still uses V1
with disable_overlap_schedule=True. The new EAGLE-V2 path is
gated behind SGLANG_GEMMA4_MTP_VIA_EAGLE=1.

The EAGLE-V2 path is NOT yet functional — it crashes during CUDA
graph capture on a hidden-state shape mismatch. This PR ships the
scaffolding + the detailed analysis of what remains; it is
documentation-quality code for the next implementer, not a working
overlap path.

Stack base

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

What ships

File Change
python/sglang/srt/speculative/gemma4_mtp_via_eagle.py (NEW) Gemma4MTPEagleWorker (subclass of EAGLEWorkerV2): calls bind_frozen_kv_context on the draft after init; monkey-patches draft_forward to snapshot+restore positions around each draft loop (Gemma-4 MTP analog of vLLM's constant_draft_positions=True).
python/sglang/srt/speculative/spec_info.py Dispatcher routes FROZEN_KV_MTP to Gemma4MTPEagleWorker when SGLANG_GEMMA4_MTP_VIA_EAGLE=1 AND overlap enabled. supports_spec_v2() now returns True for FROZEN_KV_MTP.
python/sglang/srt/arg_groups/speculative_hook.py _handle_frozen_kv_mtp still forces disable_overlap_schedule=True by default; only skips the override when the env var is set.

The remaining work (what stops this from being ship-ready)

Three sequential blockers:

Blocker 1 — spec_hidden_size mismatch (the immediate crash)

The Gemma-4 MTP assistant model expects forward_batch.spec_info.hidden_states (the recurrent prev_hidden ferried across draft iterations) to be sized [N, backbone_hidden_size] = [N, 5376] for the 31B-it target. EAGLE V2 sizes its hidden_states buffers via EagleDraftInput.hidden_size_for(worker) which returns draft_runner.model_config.spec_hidden_size = the draft model's hidden_size = 1024.

Crash during CUDA graph capture:

File "gemma4_mtp.py", line 253, in forward
    raise ValueError(
ValueError: Frozen-KV MTP forward: token_embed and prev_hidden must
have the same shape (got torch.Size([80, 5376]) vs torch.Size([80, 1024])).

Fix options (any one):

  1. Override draft_runner.model_config.spec_hidden_size = target_backbone_hidden_size before EagleDraftWorker.__init__ runs (currently it runs inside EAGLEWorkerV2.__init__ so the subclass can't intercept). Requires touching how the parent constructs the draft worker.
  2. Patch Gemma4AssistantForCausalLM to expose a spec_hidden_size class attribute that ModelConfig reads. ~10 LOC in two files.
  3. Add an EagleDraftInput.hidden_size_for override hook so the model can declare its required hidden state size.

Blocker 2 — return value contract

Even with the input shape fixed, the assistant's forward() returns LogitsProcessorOutput.hidden_states sized at the internal hidden_size=1024 (the inner Gemma4TextModel output, before post_projection). EAGLE V2 takes this output and stores it as the next iter's spec_info.hidden_states. We need the assistant to instead return post_projection(hidden_states) = [N, 5376] so the recurrent ferry stays at backbone size.

Currently the assistant passes hidden_states_before_norm=projected_states (=post_projection output) to LogitsProcessor, which preserves it as output.hidden_states_before_norm. The fix: swap which one goes into output.hidden_states for the EAGLE-V2 path.

Blocker 3 — constant_draft_positions correctness validation

My current implementation monkey-patches draft_worker.draft_forward to snapshot positions before the EAGLE draft loop and restore after. But INSIDE the loop, EAGLE V2 still does forward_batch.positions.add_(1) at line 493 of eagle_worker_v2.py between draft steps. The attention metadata is built once for draft_index=0 (outside the loop) so each draft step still calls into a kernel that sees incremented positions. Whether this produces correct outputs depends on whether the attention backend reads positions directly or only via the cached metadata. Needs validation once Blocker 1 is past.

The full vLLM approach (SpecDecodeBaseProposer.propose at llm_base_proposer.py:570-587) skips the position-update entirely when constant_draft_positions=True. Equivalent SGLang fix: add a self.constant_draft_positions: bool = False flag to EagleDraftWorker.draft_forward and gate the positions.add_(1) call (~3 LOC change in eagle_worker_v2.py:493). Low-risk if confined to spec-v2 path with explicit opt-in.

Why we shipped scaffolding instead of the full fix

Original time estimate (from the comparison report): 5-10 days for Option-α. Investigation today confirmed this is accurate — the architectural mismatch between EAGLE V2 and Gemma-4 MTP runs three layers deep (init-time buffer sizing, runtime recurrent-state shape, scheduler position-advancement semantics).

Doing this surgery correctly in this session would require:

  • Hands-on familiarity with EAGLE V2's CUDA graph capture lifecycle
  • Validation matrix: V1 EAGLE (Llama-3/Qwen) + V1 EAGLE3 + V1 FROZEN_KV_MTP + the new V2-EAGLE-MTP path, since changes touch shared code
  • 2-3 days minimum of careful iteration

Decision: ship the scaffolding + analysis as infrastructure for the next implementer, preserve V1 default behavior, and document the three remaining blockers concretely.

Verified V1 default path still works

$ curl -sf -X POST http://127.0.0.1:30000/v1/chat/completions \
    -d '{"model":"google/gemma-4-31B-it",...,"What is 2+2?"...}'
Response: 2 + 2 = 4

Server log:

[warn] Overlap scheduler is disabled for Frozen-KV MTP (V1 path;
       set SGLANG_GEMMA4_MTP_VIA_EAGLE=1 to try the experimental EAGLE-V2 path).

Reference

  • vLLM's Gemma4Proposer (the architecture this PR mirrors): vllm/v1/spec_decode/gemma4.py:31-335
  • vLLM's SpecDecodeBaseProposer.propose with constant_draft_positions: vllm/v1/spec_decode/llm_base_proposer.py:421-600
  • Full architectural comparison: agent-pod/runs/20260525_mtp_v2/analysis/vllm_vs_sglang_mtp.md

For production today

Stick with the recommendation from PR #21: use SGLang no-MTP. The MTP gap to vLLM remains until either:


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.

…LE V2 (env-gated experimental)

Adds the architectural scaffolding for Option-alpha (mirror vLLM's
approach: thin proposer subclass of generic spec-decode worker,
instead of a custom worker). Default behavior unchanged.

Background: per the vLLM-vs-SGLang MTP comparison
(agent-pod/runs/20260525_mtp_v2/analysis/vllm_vs_sglang_mtp.md),
vLLM bakes frozen-KV into the model and uses a 335-LOC Gemma4Proposer
subclass of the generic SpecDecodeBaseProposer for the scheduler hook.
SGLang's 787-LOC FrozenKVMTPWorker is the entire reason FROZEN_KV_MTP
can't use spec-V2 overlap scheduling -- the worker doesn't implement
the BaseSpecWorker interface.

This PR is the equivalent of vLLM's Gemma4Proposer for SGLang:
* New file: python/sglang/srt/speculative/gemma4_mtp_via_eagle.py
  - Gemma4MTPEagleWorker(EAGLEWorkerV2): thin subclass that
    - constructs the parent EAGLE V2 worker
    - calls bind_frozen_kv_context on the draft model after init
      (mirrors what FrozenKVMTPWorker does, but at a different entry
      point so EAGLE V2's overlap pipeline can drive it)
    - monkey-patches the draft worker's draft_forward to NOT advance
      positions between draft iterations (Gemma-4 MTP analog of
      vLLM's constant_draft_positions=True)
  - Gemma4MTPEagleDraftWorker: subclass placeholder for the
    constant-positions variant; currently the monkey-patch approach
    is used because EagleDraftWorker is constructed by the parent
    EAGLEWorkerV2 ctor.

* spec_info.py: dispatcher routes FROZEN_KV_MTP to Gemma4MTPEagleWorker
  when SGLANG_GEMMA4_MTP_VIA_EAGLE=1 is set; otherwise raises (overlap)
  or returns the V1 FrozenKVMTPWorker (--disable-overlap-schedule).
  supports_spec_v2() also updated to return True for FROZEN_KV_MTP.

* speculative_hook.py: _handle_frozen_kv_mtp still sets
  disable_overlap_schedule=True by default; only skips that when the
  env var is set. Preserves existing FROZEN_KV_MTP deployments
  unchanged.

KNOWN LIMITATION (the EAGLE-V2 path is NOT yet functional):

The Gemma-4 MTP assistant model expects the recurrent ``prev_hidden``
in spec_info.hidden_states to have backbone_hidden_size shape
(target's hidden_size, 5376 for 31B-it), NOT the assistant's internal
hidden_size (1024). EAGLE V2 sizes its hidden_states buffers using
draft_model_config.spec_hidden_size = draft.hidden_size = 1024.
CUDA-graph capture crashes:
    ValueError: Frozen-KV MTP forward: token_embed and prev_hidden
    must have the same shape (got torch.Size([80, 5376]) vs
    torch.Size([80, 1024])).

Fixing this requires either:
1. Override draft ModelConfig.spec_hidden_size BEFORE EagleDraftWorker
   constructs the draft model + allocates CUDA-graph buffers
   (~10-50 LOC in spec_info + ModelConfig)
2. Modify the assistant model's forward() to return
   hidden_states_before_norm in the LogitsProcessorOutput.hidden_states
   field, so EAGLE's recurrent ferry gets backbone-size data even
   though spec_hidden_size remains 1024 (cleaner but couples model
   internals to spec_v2 contract)
3. Add an EAGLE V2 hidden_size override hook
   (~3 lines in EAGLE V2, plus draft-side init wiring)

Beyond the hidden-size mismatch, EAGLE V2 also assumes positions
advance by 1 each draft step; the monkey-patched constant-positions
behavior may interact with attention metadata caching and CUDA graph
capture in ways not yet validated.

Verified V1 default path still works after this PR (smoke test
"What is 2+2?" -> "2 + 2 = 4", parity preserved).

To try the experimental EAGLE-V2 path (will crash):
    SGLANG_GEMMA4_MTP_VIA_EAGLE=1 python -m sglang.launch_server ...

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant