Skip to content

feat(speculative): add P-EAGLE parallel-drafting training for EAGLE-3 drafts - #2376

Merged
HuiyingLi merged 18 commits into
NVIDIA-NeMo:mainfrom
khazic:khazic/feat/eagle-parallel-drafting
Jun 4, 2026
Merged

feat(speculative): add P-EAGLE parallel-drafting training for EAGLE-3 drafts#2376
HuiyingLi merged 18 commits into
NVIDIA-NeMo:mainfrom
khazic:khazic/feat/eagle-parallel-drafting

Conversation

@khazic

@khazic khazic commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

What does this PR do ?

Adds P-EAGLE (parallel-drafting EAGLE-3) training to the speculative-decoding
stack. The EAGLE-3 draft learns to predict all K tokens in a single forward
pass (parallel multi-token prediction) instead of EAGLE-3's autoregressive
test-time-training (TTT) unroll. Checkpoints are written in vLLM's
parallel-drafting layout so they load into vLLM ≥ 0.16 unchanged.

P-EAGLE paper: https://arxiv.org/abs/2602.01469 · vLLM runtime:
vllm-project/vllm#32887 (released in v0.16.0).

Depends on #2377 (LlamaRotaryEmbedding position_ids fix). vLLM gives the
K parallel slots incrementing RoPE positions; reproducing that in training
needs the rotary to honor position_ids (EAGLE's + step_idx offset was a
no-op before #2377). This branch cherry-picks that commit so the parity test
below passes; it should be rebased onto main once #2377 merges.

Changelog

  • components/speculative/eagle/draft_llama.py: register a learnable
    mask_hidden placeholder of shape [1, num_aux_hidden_states * target_hidden_size]
    under config.parallel_drafting (guarded, same pattern as the EAGLE-3.1
    fc_norm / norm_output toggles); add masked_projected_hidden().
  • components/speculative/eagle/core.py: new PEagleTrainerModule. Depth 0
    (NTP) consumes the real token + projected target aux hidden states; depths
    ≥ 1 (MTP) consume the masked ptd_token_id token and the fixed learnable
    mask_hidden placeholder, with no recurrence. Reuses the EAGLE-3
    cache_hidden attention, the draft-vocab projection, the 0.8**d loss
    schedule, and the metrics.
  • recipes/llm/train_eagle3.py: parallel_drafting / num_draft_tokens /
    ptd_token_id recipe args. ptd_token_id is required when
    parallel_drafting=True (no silent default) and range-checked; it is written
    into the draft config.json.
  • components/speculative/serve_sglang.py: reject a P-EAGLE head with an
    actionable error — SGLang cannot serve it yet (tracked in
    [Feature] Support P-EAGLE (Parallel EAGLE) speculative decoding sgl-project/sglang#23171).
  • examples/speculative/p-eagle/llama_peagle_mvp.yaml: example config (uses a
    reserved Llama-3 special token for ptd_token_id).
  • Tests: tests/unit_tests/speculative/test_peagle.py (15) + a serve_sglang
    rejection test.

Checkpoint contract (vLLM-loadable)

vLLM's parallel-drafting loader keys on the substring mask_hidden and reads
ptd_token_id from the draft config. This PR saves the mask_hidden tensor
under that key and writes ptd_token_id into config.json; everything else is
a standard EAGLE-3 draft checkpoint. Inference is vLLM-only today (SGLang
support is upstream-tracked, not merged), documented in the example YAML and
enforced by the serve guard.

Equivalence to vLLM inference (parity)

test_peagle_parallel_forward_matches_expanded_reference builds an independent
flat expanded parallel forward — the [context, mask_1, .., mask_{K-1}] layout
vLLM runs at inference — and asserts the per-depth logits match the
cache_hidden K-step training to ~1e-7. So the K sequential training steps
are numerically equivalent to vLLM's single parallel forward, not an
approximation. (This is what surfaced the RoPE bug fixed in #2377.)

Validation (8 GPUs, Llama-3.1-8B target, PerfectBlend, K=4)

End-to-end training. Loss decreases and accuracy rises — starting loss
ln(draft_vocab = 8192) ≈ 9.0:

epoch=0 step=10  train_loss=8.631956 train_acc=0.028928
epoch=0 step=50  train_loss=6.476553 train_acc=0.097603
epoch=0 step=100 train_loss=6.132212 train_acc=0.120686

The saved consolidated checkpoint carries the P-EAGLE additions in vLLM's
expected layout (config.json: parallel_drafting=True, ptd_token_id;
safetensors: mask_hidden of shape [1, 3*4096] = [1, 12288], plus the
standard EAGLE-3 draft keys). The acceptance-length / speedup numbers can only
be measured by serving the head under vLLM ≥ 0.16 on a GPU, which is out of
scope for this training-side PR.

Before your PR is "Ready for review"

Pre checks:

  • Read and followed the Contributor guidelines
  • New tests added (parity vs flat expanded reference; mask_hidden
    key/shape; masked-inputs-vs-no-recurrence; num_draft_tokens=1 == EAGLE-3
    step 0; loss-decreases; save round-trip config.json + safetensors;
    out-of-draft-vocab masking; serve rejection)
  • Docs / example updated (example YAML + serve_sglang notes)

Local: ruff clean; pytest tests/unit_tests/speculative/test_peagle.py tests/unit_tests/speculative/test_eagle3.py tests/unit_tests/models/llama/test_rope_utils.py → 54 passed, 2 skipped
(GPU-only FlashAttention-2 paths).

Additional Information

khazic added 3 commits June 1, 2026 20:01
… drafts

P-EAGLE (https://arxiv.org/abs/2602.01469) trains the EAGLE-3 draft to predict
all K tokens in a single forward instead of EAGLE-3's autoregressive TTT unroll:
masked multi-token-prediction depths are conditioned on a fixed learnable
mask_hidden placeholder and the reserved ptd_token_id token rather than the
previous depth's output.

Implementation (reuses the EAGLE-3 cache_hidden diagonal-extension attention,
which is numerically faithful to vLLM's single-base-position decode):
- draft_llama: register a learnable mask_hidden Parameter of shape
  [1, num_aux_hidden_states * target_hidden_size] under config.parallel_drafting,
  plus masked_projected_hidden() to project it through fc/fc_norm.
- core: PEagleTrainerModule runs the parallel masked-depth loss, sharing the
  draft-vocab projection, 0.8**d weighted-mean schedule, and metrics with
  Eagle3TrainerModule.
- train_eagle3: parallel_drafting / num_draft_tokens / ptd_token_id recipe args;
  ptd_token_id is written into the draft config.json.

Checkpoint format matches vLLM's parallel-drafting loader (vllm-project/vllm#32887,
v0.16.0): the mask_hidden tensor is saved under the key 'mask_hidden' and
ptd_token_id rides in config.json, so the head loads into vLLM unchanged.

Inference is vLLM-only today; serve_sglang now rejects a parallel_drafting head
with an actionable error (SGLang support tracked in sgl-project/sglang#23171).

Tests: tests/unit_tests/speculative/test_peagle.py (mask_hidden key/shape, mask
inputs vs no-recurrence, K=1 == EAGLE-3 step 0, loss decreases, state-dict
round-trip) and a serve_sglang rejection test. CPU-only.

Signed-off-by: khazic <khazzz1c@gmail.com>
… num_draft_tokens

recipe_cfg.get('num_draft_tokens', recipe_cfg.ttt_steps) evaluates the
recipe_cfg.ttt_steps default argument unconditionally (Python always computes a
dict.get default), so a P-EAGLE config that correctly omits ttt_steps raised
AttributeError in setup() before any training step. Resolve through a nested
.get with a literal fallback (num_draft_tokens -> ttt_steps -> 4) so the missing
key is never touched. Add a regression test that loads the committed P-EAGLE
example config and asserts it carries no ttt_steps and still resolves K.

Signed-off-by: khazic <khazzz1c@gmail.com>
masked_projected_hidden() and the batch hidden_states are both outputs of the
same model.fc projection, so they already share the compute dtype; the
.to(hidden_states.dtype) was a no-op. Removing it matches Eagle3TrainerModule,
which does not cast. No behavior change (unit tests unchanged).

Signed-off-by: khazic <khazzz1c@gmail.com>
@copy-pr-bot

copy-pr-bot Bot commented Jun 1, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@HuiyingLi

Copy link
Copy Markdown
Contributor

/ok to test 76450e0

LlamaRotaryEmbedding.forward keyed only on position_ids.shape[-1] (the sequence
length) and returned cos_cache[:seq_len], silently ignoring the position
values. Any non-contiguous position_ids therefore received the wrong rotary
phase: EAGLE TTT depth offsets (arange(seq_len) + step_idx), packed sequences,
and context parallelism. In particular EAGLE's intended per-step phase shift was
a no-op.

Gather cos/sin by the actual position values and size the cache to the highest
requested position (which can exceed seq_len for TTT offsets). For the common
position_ids == arange(seq_len) case the gather is numerically identical to the
previous slice, so standard training/inference is unchanged. The fused TE path,
which consumes raw angles by sequence index, keeps the legacy contiguous slice.

Add tests/unit_tests/models/llama/test_rope_utils.py covering the arange no-op,
the offset case, non-contiguous gather, and out-of-seq_len positions.

Signed-off-by: khazic <khazzz1c@gmail.com>
@HuiyingLi

Copy link
Copy Markdown
Contributor

/ok to test 5767091

Signed-off-by: khazic <khazzz1c@gmail.com>
@HuiyingLi

Copy link
Copy Markdown
Contributor

/ok to test 8a8a7bb

thyways and others added 2 commits June 3, 2026 02:15
Signed-off-by: thyways <2484113689@qq.com>
fix(speculative): add Qwen P-EAGLE training fixes
@HuiyingLi

Copy link
Copy Markdown
Contributor

/ok to test 788f917

@HuiyingLi

Copy link
Copy Markdown
Contributor

/ok to test 1f91013

@HuiyingLi

Copy link
Copy Markdown
Contributor

/ok to test 1689989

…arallel-drafting

Signed-off-by: khazic <khazzz1c@gmail.com>

# Conflicts:
#	nemo_automodel/recipes/llm/train_eagle3.py
@HuiyingLi

Copy link
Copy Markdown
Contributor

/ok to test 5dbeddb

Fixes the 9 failing L0 CPU unit tests on this branch.

1. draft_llama: the P-EAGLE flex attention was a module-level
   torch.compile(flex_attention, mode="max-autotune-no-cudagraphs") called
   unconditionally. Inductor's flex backend is unavailable on CPU and raises
   InductorError, so all six test_peagle cases failed. Dispatch to the compiled
   callable only on CUDA and fall back to eager flex_attention on CPU (correct,
   just slower) -- the compiled callable stays lazy so CPU import is free.

2. eagle3 token mapping: wrapping the dataloader in tqdm(dataloader) triggers a
   second __iter__ on loaders without __len__, double-scanning the data. This
   broke the three test_eagle3_token_cache "scan exactly once" assertions and is
   a real bug for single-pass / streaming loaders. Drive the progress bar
   manually and iterate the dataloader directly so it is scanned exactly once.

Signed-off-by: khazic <khazzz1c@gmail.com>
@HuiyingLi

Copy link
Copy Markdown
Contributor

/ok to test 92e0f80

@HuiyingLi

Copy link
Copy Markdown
Contributor

/claude review

# dst=Path('./cache/dataset/perfectblend-qwen3-8b-regen-messages'); dst.mkdir(parents=True, exist_ok=True); \
# files=sorted(f for f in list_repo_files(repo, repo_type='dataset') if f.startswith('data/train-') and f.endswith('.parquet')); \
# [pd.read_parquet(hf_hub_download(repo_id=repo, repo_type='dataset', filename=f)).rename(columns={'conversations':'messages'}).to_parquet(dst / Path(f).name, index=False) for f in files]; \
# print(f'wrote{len(files)} shards to {dst}')"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: missing space in the f-string — this would print wrote5 shards instead of wrote 5 shards.

Suggested change
# print(f'wrote{len(files)} shards to {dst}')"
# print(f'wrote {len(files)} shards to {dst}')"

lr: 1e-4
betas: [0.9, 0.95]
weight_decay: 0
warmup_ratio: 0.015 No newline at end of file

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: missing newline at end of file.

The P-EAGLE draft forward runs flex_attention, whose autograd is not
implemented on CPU in the CI torch build -- even the forward errors with
'FlexAttention does not support backward on CPU' once the inputs require
grad. The six tests that drive the draft forward built the model on CPU, so
they failed in both the CPU and GPU unit-test jobs.

Make the draft / trainer / batch device-aware (CUDA when available) and skip
those six tests when CUDA is unavailable; the pure-tensor COD, mask-mod,
draft-contract, and config tests stay on CPU. Locally (no CUDA) the suite is
13 passed, 6 skipped.

Signed-off-by: khazic <khazzz1c@gmail.com>
@HuiyingLi

Copy link
Copy Markdown
Contributor

/ok to test 927dfbf

…arallel-drafting

Signed-off-by: khazic <khazzz1c@gmail.com>

# Conflicts:
#	nemo_automodel/components/speculative/eagle/__init__.py
@HuiyingLi

Copy link
Copy Markdown
Contributor

/ok to test 2da9eea

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.

4 participants