feat(speculative): add serve_vllm for EAGLE-3 / P-EAGLE drafts - #2841
Merged
HuiyingLi merged 2 commits intoJun 30, 2026
Conversation
SGLang cannot serve a parallel-drafting (P-EAGLE) head; its inference runtime is vLLM's parallel-drafting path (vLLM >= 0.16). Add a vLLM companion to serve_sglang that resolves a recipe drafter checkpoint (epoch_*/model[/consolidated]), rewrites the architectures field from the Automodel LlamaEagle3DraftModel name to vLLM's LlamaForCausalLMEagle3, auto-detects parallel_drafting and derives num_speculative_tokens from the draft num_depths, then launches the OpenAI-compatible api_server with the right --speculative-config. The draft already stores d2t/t2d in its weights in vLLM's offset form, so no separate token map is emitted. Signed-off-by: khazic <khazzz1c@gmail.com>
Contributor
|
/ok to test 54bf059 |
Contributor
|
/claude review |
Comment on lines
+236
to
+244
|
|
||
| def _export_is_fresh(draft_dir: Path, export_dir: Path) -> bool: | ||
| """True when ``export_dir`` holds a complete config + weights newer than the source.""" | ||
| exported_config = export_dir / "config.json" | ||
| if not exported_config.exists() or not _has_hf_weight_file(export_dir): | ||
| return False | ||
| src_weights = [*draft_dir.glob("model-*.safetensors"), draft_dir / "model.safetensors"] | ||
| src_mtime = max((w.stat().st_mtime_ns for w in src_weights if w.exists()), default=0) | ||
| return exported_config.stat().st_mtime_ns >= src_mtime |
Contributor
There was a problem hiding this comment.
Bug: the staleness check only compares the exported config's mtime against source weight files, but ignores the source config.json mtime. If a user re-trains and the source config.json is updated (e.g. mask_token_id changes, or parallel_drafting is toggled) without the weight files being touched, _export_is_fresh returns True and the export is reused with a stale pard_token / architectures.
Suggested change
| def _export_is_fresh(draft_dir: Path, export_dir: Path) -> bool: | |
| """True when ``export_dir`` holds a complete config + weights newer than the source.""" | |
| exported_config = export_dir / "config.json" | |
| if not exported_config.exists() or not _has_hf_weight_file(export_dir): | |
| return False | |
| src_weights = [*draft_dir.glob("model-*.safetensors"), draft_dir / "model.safetensors"] | |
| src_mtime = max((w.stat().st_mtime_ns for w in src_weights if w.exists()), default=0) | |
| return exported_config.stat().st_mtime_ns >= src_mtime | |
| exported_config = export_dir / "config.json" | |
| if not exported_config.exists() or not _has_hf_weight_file(export_dir): | |
| return False | |
| src_weights = [*draft_dir.glob("model-*.safetensors"), draft_dir / "model.safetensors"] | |
| src_config = draft_dir / "config.json" | |
| src_files = [w for w in src_weights if w.exists()] + ([src_config] if src_config.exists() else []) | |
| src_mtime = max((f.stat().st_mtime_ns for f in src_files), default=0) | |
| return exported_config.stat().st_mtime_ns >= src_mtime |
Comment on lines
+163
to
+172
| model_dir = tmp_path / "model" | ||
| _write_draft_checkpoint(model_dir, architectures=["LlamaEagle3DraftModel"]) | ||
| export_config = model_dir / "vllm_export" / "config.json" | ||
|
|
||
| resolve_draft_artifacts(str(model_dir)) | ||
| first_mtime = export_config.stat().st_mtime_ns | ||
| resolve_draft_artifacts(str(model_dir)) | ||
| assert export_config.stat().st_mtime_ns == first_mtime, "fresh export must be reused, not rebuilt" | ||
|
|
||
|
|
Contributor
There was a problem hiding this comment.
Missing coverage: the docstring says "touching the source weights rebuilds it" but the test only verifies the cache-hit path. Consider adding a second half that touches the source weight file and asserts the export is rebuilt:
# Touch source weights -> export must be rebuilt.
(model_dir / "model.safetensors").write_bytes((model_dir / "model.safetensors").read_bytes())
resolve_draft_artifacts(str(model_dir))
assert export_config.stat().st_mtime_ns > first_mtime, "stale export must be rebuilt after source changes"_export_is_fresh only compared the export against the source weight mtimes, so a config-only edit (e.g. toggling parallel_drafting or changing mask_token_id) reused a stale export carrying the old architectures / pard_token fixups. Include config.json in the freshness check, and add a test covering the rebuild path for both weight and config changes. Signed-off-by: khazic <khazzz1c@gmail.com>
24 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds
nemo_automodel/components/speculative/serve_vllm.py, the vLLM companion toserve_sglang. It serves an Automodel-trained EAGLE-3 / P-EAGLE drafter undervLLM's parallel-drafting runtime.
Why
The P-EAGLE recipe (
parallel_drafting: true) produces a head that SGLang cannotserve today, so
serve_sglangrejects it and points at vLLM. There was no vLLMentry point, so a trained P-EAGLE draft could not actually be served or
benchmarked (the
qwen_peagle_perfectblend.yamlrecipe comment already notesthat acceptance can only be measured by running the head under vLLM). This closes
that gap.
How
serve_vllmresolves a recipe checkpoint (epoch_*/model[/consolidated]),applies three fixups so vLLM can load the draft, then shells out to
vllm.entrypoints.openai.api_serverwith the right--speculative-config:architectures: rewrite Automodel'sLlamaEagle3DraftModelto vLLM'sregistered
LlamaForCausalLMEagle3.pard_token(P-EAGLE): vLLM's parallel-drafting proposer reads the masked-slotid from
pard_token/ptd_token_id/dflash_config.mask_token_id, but therecipe writes only the top-level
mask_token_id. The value is copied over,mirroring vLLM's own speculators
update_peaglemapping.self.model, so weights are saved asmodel.*; vLLM re-addsmodel.to every non-top-level weight, double-prefixingthem. The prefix is stripped into a one-time
vllm_export/copy, leaving thesource checkpoint untouched. A draft whose weights are already vLLM-standard
skips the export and gets the config fixups in place.
The draft already stores the
d2t/t2dvocab-remap buffers in the offset formvLLM expects, so (unlike the SGLang path) no separate speculative-token-map file
is emitted.
Validation
Trained a standard P-EAGLE Qwen3-8B draft and served it through
serve_vllmonvLLM's parallel-drafting runtime: the draft loads and parallel drafting runs,
producing a measurable acceptance length end to end. New unit tests cover artifact
resolution, all three fixups, the export vs in-place split, export caching, and
CLI dispatch (
--print-only, missing-vLLM exit, subprocess / execv dispatch).Notes
pyproject.toml; the script exits with an install hint when it is missing.