Skip to content

feat(eagle3): Qwen3 EAGLE-3 drafter forward pass (single-step + chain rollout)#707

Open
scatyf3 wants to merge 22 commits into
openinfer-project:mainfrom
scatyf3:feat/eagle3-drafter-forward
Open

feat(eagle3): Qwen3 EAGLE-3 drafter forward pass (single-step + chain rollout)#707
scatyf3 wants to merge 22 commits into
openinfer-project:mainfrom
scatyf3:feat/eagle3-drafter-forward

Conversation

@scatyf3

@scatyf3 scatyf3 commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

This is an follow up PR for #662

What

Adds the EAGLE-3 drafter forward path for the Qwen3 line, on top of the drafter config/loading skeleton. The core change is in openinfer-qwen3/src/eagle3/forward.rs

Code

  • draft_step: one drafter decoding step
  • prefill_prompt / prefill_batched: teacher-forced prefill that builds the
    drafter KV and the boundary target feature from captured hidden states.
  • draft_chain: autoregressive chain of γ draft tokens from the
    fused target boundary hidden; v1 syncs logits to host per step for the argmax.
  • chain_round: one speculative round: init the residual stream from
    fc(boundary feature), draft γ tokens, then rewind the speculative KV so the
    round is side-effect-free except for the returned span.
  • rebuild_after_verify: after a verify step, teacher-force the accepted
    prefix back into the drafter KV and set the new boundary target feature for the next round.

Testing

  • cargo test --release -p openinfer-qwen3 --lib — reservation geometry unit
    test passes; cargo fmt/clippy clean.
  • No GPU e2e here — the forward path is exercised end-to-end by the follow-up
    scheduler PR that consumes these entry points and perform accuracy check vs hf golden gate.

scatyf3 and others added 21 commits July 10, 2026 14:22
Add the single-sequence custom-mask attention kernels the EAGLE-3 drafter
forward needs (prefill_attention.cu / paged_attention.cu) with their FFI
declarations and safe openinfer-kernels / openinfer-core op wrappers. No
caller yet; the drafter loading and forward land in follow-up PRs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…isibility

Address review on the new attention entries (openinfer-project#647):

- Wrap single_prefill_nhd_causal_cuda / single_decode_nhd_cuda in
  OPENINFER_FFI_GUARD_BEGIN/END(-1) like their siblings — FlashInfer's
  DISPATCH_GQA_GROUP_SIZE throws for group sizes outside {1,2,3,4,8}, and
  without the guard the C++ exception crossed the extern "C" boundary and
  aborted the process instead of returning -1. The two Rust wrappers now
  append ffi_exception_message(result) to their bail messages.
- Reject num_qo_heads % num_kv_heads != 0 in both entries: FlashInfer
  truncates group_size = qo/kv, so a non-divisible pair silently served
  fewer heads and returned cudaSuccess, leaving stale/uninitialized output.
- Assert sin_cache.data.len() == cos_cache.data.len() in eagle3_rope_into;
  cos_max_pos was derived from cos_cache alone, so a shorter sin_cache let
  the kernel read out of bounds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…AGLE-3 wrappers

Second review round on openinfer-project#647 — soundness of the safe pub fn entry points:

- Add checked_row_offset (checked_add for offset+span, checked_mul for the
  byte offset) to the two row-offset wrappers eagle3_rope_into and
  single_prefill_nhd_causal_into. Release builds leave overflow-checks off, so
  a usize::MAX offset previously wrapped past the range assert and the pointer
  arithmetic, faulting only at the next sync as CUDA_ERROR_ILLEGAL_ADDRESS; it
  now returns Err before entering FFI.
- Add ensure_hidden_capacity (require data.len() >= hidden_dim * seq_len — >=,
  not ==, to keep the capacity-backed HiddenStates convention where buffers are
  allocated at a max and .seq_len is rewritten per step) across the Q/K/V/output
  buffers of all three new wrappers. A safe caller inflating .seq_len past a
  small allocation now returns Err instead of silently reading out of bounds.

Pre-existing siblings (dflash_qk_norm_rope_into, single_prefill_nhd_noncausal_into)
share both gaps; left as follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the EAGLE-3 drafter model skeleton: Eagle3Config (parse + validate
against the target's geometry/vocab/rope), the Eagle3DraftModel /
Eagle3Layer weight structs, safetensors loading (fc fusion, midlayer,
draft lm_head, d2t/t2d vocab maps, rope caches), and the drafter KV
memory reservation. The forward pass is not present yet (module is
dead-code-allowed); it lands in the next PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two lints broke the `openinfer-server` CUDA clippy job (`-D warnings`):

- `chunks_exact_to_as_chunks` in `load_tensor_i64_host`: use
  `as_chunks::<8>()` over the byte slice, which also drops the
  `try_into().unwrap()` since each chunk is already `[u8; 8]`.
- `unused_imports` on the `Eagle3MemoryReservation` re-export: the
  module's `#![allow(dead_code)]` doesn't cover unused imports, and the
  reservation is only wired into the KV budget by the forward-pass PR.
  Mark the re-export `#[allow(unused_imports)]` with a note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… tautological test

aux_hidden_state_layers returned the reference's pre-layer gate (2, N/2, N-3)
but fed it into openinfer's post-layer / 0-based capture path (prefill.rs,
verify_graph.rs) — one block late for all three fc inputs. SafeAILab traineagle3
appends the hidden state *before* the block (vLLM matches it via a post-layer
idx+1 capture), so the equivalent post-layer indices are (1, N/2-1, N-4) =
[1, 17, 32] for the 36-layer Qwen3-4B target.

Also drop aux_layers_qwen3_4b: it was a constant-true echo of the formula.
Unlike DFlash (whose target_layer_ids are parsed from the checkpoint and
cross-checked), EAGLE-3's AngelSlim config carries no capture-layer field, so
there is no external value to validate against. End-to-end correctness is
covered by the drafter golden gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`-p openinfer-qwen3 --all-targets` compiles the lib-test target, which the
upstream `-p openinfer-server` clippy job never reaches — so two lints slipped
through the earlier drafter-loader clippy pass:

- items_after_test_module (eagle3.rs): the `#[cfg(test)] mod tests` sat above
  Eagle3Layer/Eagle3DraftModel. Move it to the bottom of the file.
- unreadable_literal (reservation.rs): separators on the 5+ digit fields of the
  Qwen3-4B eagle3 test config (151_936 / 32_000 / 40_960).

Test code only; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`load_tensor_i64_host` / `load_tensor_bool_host` only checked byte-length
divisibility, so a mistyped tensor (e.g. F64, also 8-byte) was silently
reinterpreted as I64 and a U8 tensor as BOOL; a >1D shape passed too.
Enforce `Dtype::I64` / `Dtype::BOOL` and rank-1 before the raw-byte read.

Addresses PR openinfer-project#662 review (FeathBow).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… exactly

Two review gaps in the drafter loader:

- Only `fc` was shape-checked; q/k/v, o, gate/up/down, all norms and lm_head
  were loaded blind, and `q_dim`/`kv_dim` were derived from the loaded Q/K
  tensors rather than the config. A malformed checkpoint (e.g. a `v_proj`
  with the wrong row count) could build an internally inconsistent draft,
  since `vstack` only requires equal columns. Derive the dims from config
  and check every tensor's shape against it before stacking or storing.

- The two BF16 RoPE caches `[max_position_embeddings, head_dim]` were only
  covered by the generic 10% weight slack. `validate_for_target` only
  lower-bounds the draft limit to the target's, so a 131072-position draft
  overruns the slack by ~23 MB. Bill the exact term in `fixed_bytes` and
  pin the delta in the test.

Addresses PR openinfer-project#662 review (FeathBow).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implement the EAGLE-3 drafter forward on the loaded model: per-request
state/scratch, the single `midlayer` step (fc-fuse captured target hidden
states, dual-input attention via the custom-mask kernels, MLP, draft
lm_head, d2t remap), batched teacher-forcing prefill, and the top-1 chain
rollout (draft_chain / chain_round / reseed_after_verify). Not yet wired
into the executor; correctness gate lands in the next PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Rename the single-char locals in `prefill_batched` (n/q/k/v/o ->
  num_tokens/query/key/value/attn_proj) to clear `many_single_char_names`.
- `#[allow(unused_imports)]` on the `forward::{Eagle3RequestState,
  Eagle3Scratch}` re-export (consumed by the later scheduler PR), mirroring
  the reservation export.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Align the boundary target-feature field with vLLM's EAGLE-3 naming: it holds
the low/mid/high aux hidden state (pre-`fc`) at the last committed position,
which vLLM calls `aux_hidden_states`. No behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The forward pass writes K/V and runs the midlayer through *unchecked* gemms
(`gemm_into`/`gemm_rows_into` don't validate dims), trusting that the passed
`Eagle3RequestState`/`Eagle3Scratch` were allocated for this drafter. A state
or scratch from a differently-shaped drafter would silently corrupt the KV
cache / produce garbage rather than error — the same "internally inconsistent"
class flagged on the loader (openinfer-project#662).

- `ensure_state_geometry` / `ensure_scratch_geometry`: one-shot dim asserts at
  the leaf consumers (`draft_step`, `prefill_batched`, `seed_hidden_from_context`).
- `draft_chain`: fail up front when `start + k` overruns `max_cache_len`, before
  writing any speculative KV, instead of mid-chain in `draft_step`.
- `debug_assert` the `aux_hidden_states` boundary dim where it feeds `fc`.

No behaviour change on valid inputs; the host-side integer checks are dwarfed by
the per-step device sync already in `draft_chain`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `// 1.`..`// 14.` step comments mirroring `draft_step`, so the two forward
paths read side by side. prefill_batched has two extra numbered stages the
single-token path lacks: the fc teacher-forcing residual (2) and the boundary
last-hidden seed (14). Comment-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Restructure the dense doc block into rule -> concrete mapping (bulleted) ->
params -> v1 constraint. Same content, readable. Comment-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The field holds the *last* committed position's aux hidden state (`[3*hidden,1]`,
pre-fc) — the name now says both "last/boundary" and "aux (3-layer)". Drop the
"seed" wording for it: it was conflated with the post-fc *chain seed*
(`fc(..)` output that seeds the draft residual), which keeps its name. The local
that captures it is now `boundary`/`new_boundary`. Comment-and-name only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The chain seed passed by `chain_round` is `fc(last_aux_hidden_states)` — the
fused target boundary hidden `[hidden,1]`, not "the prefill's last decoder
output" the doc claimed. Rename the param and correct the description. Also note
on `prefill_batched` that its `(logits, last_hidden)` return is not consumed yet
(reserved for the scheduler PR). No behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hidden naming

The EAGLE papers and the reference/vLLM/SGLang implementations describe this
step as the draft's "feature" / "input hidden", not a "seed". Align the eagle3
draft vocabulary with the field:

- fn seed_hidden_from_context -> fuse_input_hidden_from_context
- fn reseed_after_verify      -> rebuild_after_verify
- var seed                    -> fused_target_hidden (matches draft_chain's param)
- comments/errors: chain seed / re-seed -> input hidden / rebuild

Docs-and-naming only; no behavior change. Both renamed fns have no callers
(WIP scaffolding), so the rename is contained to this file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
clippy::doc_comment_double_space_linebreaks fired on the draft_chain doc's
trailing two-space line break. Remove the trailing whitespace.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve add/add conflict in openinfer-qwen3/src/eagle3.rs: keep the
forward-pass module (mod forward + Eagle3RequestState/Eagle3Scratch
exports) this branch adds on top of main's openinfer-project#662 loading/reservation
skeleton. Comment wording ("scheduler PR" for the reservation export)
kept from this branch since the forward pass now lands here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

xiaguan commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

Could you account for the extra committed token in the capacity check? draft_chain allows start_position + k <= max_cache_len, but when all k drafts match, rebuild_after_verify commits k + 1 tokens (the current token plus k drafts). For example, start=97, k=3, max=100 passes drafting but overflows during rebuild. Please either reserve space for k + 1 or shrink k near the boundary, and add an all-accepted boundary test.

@scatyf3

scatyf3 commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for catching the bug, I already fix that

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.

2 participants