[Feature] Vanilla speculative decoding (standalone, opt-in offline interface) - #134
[Feature] Vanilla speculative decoding (standalone, opt-in offline interface)#134javierlimt6 wants to merge 12 commits into
Conversation
Phase 1 lands the pure greedy verification function and 15 parametrised unit tests, all passing. Phase 2 lands SpeculativeEngine on a HuggingFace transformers + DynamicCache backend with a DummyDraft for exercising the rejection path; integration test passes on 2/3 prompts and exposes a known bf16 K-batch-vs-incremental near-tie divergence at token 8 of the third prompt (diagnosed via tests/speculative/_probe_phase2.py — not a cache or alignment bug). Phases 3-5 deferred. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…integration kept local The previous tests/speculative/ directory mixed contributor-local integration tests (hardcoded /home/javierlimt6/work/models/Qwen3-1.7B path, requires a specific GPU) with the pure verify-math unit tests. Restructure to match the repo's one-test-file-per-feature norm (cf. tests/core/test_scheduler.py): - New tests/core/test_speculative.py: call_if_main script with six hand-crafted verify_drafts smoke scenarios. No model load, no contributor-local paths. - The integration test on Qwen3-1.7B, the bf16 diagnostic probe, and the full 15-case pytest module for verify_drafts now live outside the tree (managed via .git/info/exclude) for local verification only. Also drops a stale phase_0_spike_report.md reference from engine.py's docstring.
VanillaDraft is the first real draft strategy: a smaller model from the target's family (intended pair: Qwen3-0.6B drafting for Qwen3-1.7B) using HuggingFace transformers + DynamicCache. Per draft round it does K+1 forwards — K generate the candidate tokens, one trailing feed adds the K-th token's KV so the draft cache stays aligned 1:1 with the target's K+1-wide verify pass. This makes rollback a plain truncate-by-rejected on both sides; the alternative (K forwards) leaves the draft one KV behind after every all-accept round and silently kills acceptance. Protocol cleanups taken at the same time: - Drop the redundant `k` parameter from `draft(last_token, k)` — both drafters already store self.k and the engine passes it from its own self.k, so the parameter and `assert k == self.k` are dead weight. - Keep proposals on-device through the loop, sync once via tolist() at the end instead of int(...item()) per iteration (K device syncs → 1). - Pass `config=self.model.config` to DynamicCache so sliding-window draft models (Gemma, Mistral) get the right layer types. - Drop the `if rejected_count > 0` guard in rollback — DynamicCache.crop already short-circuits on no-op, matching the unguarded target-side crop in the engine.
The class is the standalone-draft-model strategy (a separate smaller LLM drafting for the target), which SGLang calls STANDALONE — distinct from its EAGLE/MTP feature-based heads. "Vanilla" describes the algorithm (single-chain greedy verification), not the draft type, so the old name conflated the two. The module/engine keep the "vanilla speculative decoding" naming for the algorithm; only the draft class is renamed.
Align all spec identifiers with upstream sglang's spec-decoding naming: - verb form, drop -ed: accepted -> accept (Rule 1) - accept_* includes bonus; correct_* excludes bonus (Rule 3) - the always-emitted +1 token is bonus_token (Rule 2) - markers: num_ for counts, _ct for counters, _rate for ratios (Rule 4) - drop redundant _token_id/_token_ids in spec scope (Rule 5) verify_drafts(draft_tokens, target_logits) -> (accept_tokens, num_correct_drafts); internal recovery token renamed bonus_token. Engine counters become target_forward_ct / verify_ct / completion_tokens / num_correct_drafts / num_proposed_drafts, exposing accept_length (tau, incl bonus) and accept_rate (alpha, excl bonus) — replacing average_acceptance_length (which was mean correct-drafts, ~= accept_length - 1). Draft protocol: rollback(num_reject_drafts).
- engine: accumulate completion_tokens with += (was =) so accept_length / accept_rate stay consistent across generate() calls; document counters as cumulative and reset via reset_stats() - engine: validate k >= 1 and non-empty prompt; raise ValueError (with a message) on draft/k length mismatch instead of a bare assert that python -O would strip - engine: build the prefill cache as DynamicCache(config=self.target.config), matching the draft and avoiding a full-vs-config cache asymmetry - engine: soften the docstring's correctness claim to "matches the same K-batch verify pass" (bf16 near-ties differ from per-token greedy) and note the greedy-only, full-attention-only assumptions - draft: validate k >= 1 - test: expose verify scenarios as test_verify_drafts() so pytest collects them, keeping call_if_main main() for direct execution Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`python -m minisgl.speculative --target-model X --prompt "..."` runs plain greedy; adding `--speculative-draft-model Y` (alias `--draft-model`) enables speculation, mirroring sglang's default-off `--speculative-algorithm` toggle. Prints the decoded text plus accept_length / accept_rate / target_forwards for the speculative path. The greedy fallback lives in the CLI so the engine is unchanged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- CLI: drop the --device flag, the redundant aliases, and the greedy fallback (which re-imported transformers and duplicated model loading); --draft-model is now required and the command delegates entirely to SpeculativeEngine/StandaloneDraft. ~75 -> ~22 lines. - docs/features.md: add a Speculative Decoding section with the offline CLI usage and full-attention/same-family constraints. - docs/structures.md, README.md: mention the python -m minisgl.speculative CLI. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The offline CLI rendered non-EOS control tokens (e.g. <|im_end|>) literally; decode with skip_special_tokens=True so chat/instruct targets print clean text. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
@DarkSharpness for your review if the proposed changes are good, i wanted a minimal implementation of speculative decoding for educational understanding |
|
It seems like a standalone offline demo, not integrated with minisgl runtime. Why should we land it in the main repo instead of a separate one? 🤔 |
@YzXiao101 @DarkSharpness actually yes this standalone was intended so that the algorithm is self contained and since mini-sglang is made to be small thus i feel decoupling the logic between the algorithms made sense and would reduce the code volume / blast radius of the PR however, if you believe that integrating this with the serving engine is more feasible to be merged ill be more than happy to work on that! this is technically the cleanest implementation and probably the end goal as well |
|
@YzXiao101 i made this PR as speculative decoding is such a big talking point but there are no issues or PRs about it, i thought working on a minimal implementation of speculative decoding and analysing benchmarks would be meaningful |
Motivation
Add vanilla speculative decoding (single linear chain, greedy verification — Leviathan/Chen 2023) to mini-sglang as a standalone, opt-in offline interface. This v1 lands the algorithm and correctness infrastructure with a minimal blast radius: a single new package whose only mini-sglang touchpoint is a pure verification function. Deep
Engine/Schedulerintegration (which is where the wall-clock win lives) is an explicit follow-up; see Known limitations.The default serving path is untouched — speculation is reachable only by explicitly constructing
SpeculativeEngineor running the offline CLI, so existing latency is unaffected.Modifications
New package
python/minisgl/speculative/(additive; nothing else imports it eagerly):verify.py—verify_drafts(draft_tokens, target_logits) -> (accept_tokens, num_correct_drafts). Pure, framework-agnostic greedy verification: accept the matching prefix of K drafts against the target's K+1 verify-pass logits, then append the target's bonus token. This is the one reusable touchpoint for the future engine integration.engine.py—SpeculativeEngine: single-request, greedy speculative loop on a HuggingFacetransformers+DynamicCachebackend. Exposes cumulativeaccept_length(τ) andaccept_rate(α) counters matching sglang'stokenizer_managerdefinitions.draft.py—StandaloneDraft: autoregressive draft from a smaller same-family model. Uses a K+1 trailing feed so the draft KV cache stays aligned with the target's K+1 verify pass, makingrollback()a plain cache truncation.__main__.py— one-shot offline CLI:python -m minisgl.speculative --target-model X --draft-model Y --prompt "..."runs greedy speculative decoding and prints the decoded text plusaccept_length/accept_rate.tests/core/test_speculative.py— upstream unit test forverify_drafts(pure-CPU; collected bypytestastest_verify_draftsand runnable directly viacall_if_main, matchingtest_scheduler.py).docs/structures.md— one-line module entry.Naming throughout follows sglang's spec-decoding conventions (
accept/correct/bonus,num_/_ct/_rate).Accuracy Tests
verify_draftsunit scenarios (CI, pure-CPU): all-correct + bonus, all-reject, every interior partial-accept position, and the K=1 boundary.SpeculativeEngineoutput is byte-for-byte identical to HFassisted_generation(which shares the same K-batch verify path) on Qwen3-1.7B target + Qwen3-0.6B draft, 4/4 prompts including the bf16 near-tie case ("Once upon a time,"). Note: equality is against the same K-batch compute path; in bf16 it can differ from per-token greedy at logit near-ties — this is a floating-point reordering artifact, not a logic bug.Speed Tests and Profiling
Reported honestly: at batch=1 on an RTX 4060 Laptop (8 GB), this v1 is a wall-clock regression, not a speedup — 0.55–1.13× depending on prompt and model pair (Qwen2.5-3B/0.5B ≈ break-even; Qwen3-1.7B/0.6B a clear loss). Although accepted drafts cut target forwards substantially, each round adds K+1 draft forwards, so the total forward count rises; at batch=1 every forward is launch/bandwidth-bound and a small-draft forward is not proportionally cheaper than a target forward.
Speedup is gated on the deferred work: CUDA-graph capture of the draft loop (lands with engine integration), a larger target/draft cost ratio (8B+ target), and/or batching. Because the feature is opt-in, no existing path regresses.
Known limitations / follow-ups
Engine/Scheduler(offlineSpeculativeEngine+ CLI only) — the integrated worker is the planned follow-up and the prerequisite for a wall-clock win.DynamicCache.cropraises past the window). Validated on full-attention Qwen3.<|eot_id|>) may differ frommodel.generate()in the tail.Checklist
tests/core/test_speculative.py).docs/structures.md).