Skip to content

[Feature] Vanilla speculative decoding (standalone, opt-in offline interface) - #134

Open
javierlimt6 wants to merge 12 commits into
sgl-project:mainfrom
javierlimt6:feat/spec-decoding
Open

[Feature] Vanilla speculative decoding (standalone, opt-in offline interface)#134
javierlimt6 wants to merge 12 commits into
sgl-project:mainfrom
javierlimt6:feat/spec-decoding

Conversation

@javierlimt6

Copy link
Copy Markdown

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/Scheduler integration (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 SpeculativeEngine or running the offline CLI, so existing latency is unaffected.

Modifications

New package python/minisgl/speculative/ (additive; nothing else imports it eagerly):

  • verify.pyverify_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.pySpeculativeEngine: single-request, greedy speculative loop on a HuggingFace transformers + DynamicCache backend. Exposes cumulative accept_length (τ) and accept_rate (α) counters matching sglang's tokenizer_manager definitions.
  • draft.pyStandaloneDraft: 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, making rollback() 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 plus accept_length/accept_rate.
  • tests/core/test_speculative.py — upstream unit test for verify_drafts (pure-CPU; collected by pytest as test_verify_drafts and runnable directly via call_if_main, matching test_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_drafts unit scenarios (CI, pure-CPU): all-correct + bonus, all-reject, every interior partial-accept position, and the K=1 boundary.
  • End-to-end byte-equality (local, GPU): SpeculativeEngine output is byte-for-byte identical to HF assisted_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.
  • Measured per-round acceptance (out of K=4): 3.55 factual, 3.68 code, 1.89 creative. Target forward calls for 100 new tokens dropped to ~23 (factual/code) / ~36 (creative) vs ~101 non-speculative.

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

  • Not wired into the serving Engine/Scheduler (offline SpeculativeEngine + CLI only) — the integrated worker is the planned follow-up and the prerequisite for a wall-clock win.
  • Greedy only (v1); sampling unsupported.
  • Single request; no continuous batching.
  • Full-attention models only — sliding-window draft caches cannot be cropped on rollback (DynamicCache.crop raises past the window). Validated on full-attention Qwen3.
  • Stops on a single EOS id; models with a multi-token stop set (e.g. Llama-3 <|eot_id|>) may differ from model.generate() in the tail.

Checklist

  • Code formatted (pre-commit).
  • Unit tests added (tests/core/test_speculative.py).
  • Documentation updated (docs/structures.md).
  • Accuracy + speed results provided (above; speed reported honestly as a v1 regression with the path to a win).
  • Naming/code style follows sglang conventions.

javierlimt6 and others added 12 commits May 19, 2026 00:09
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>
@javierlimt6

Copy link
Copy Markdown
Author

@DarkSharpness for your review if the proposed changes are good, i wanted a minimal implementation of speculative decoding for educational understanding

@YzXiao101

Copy link
Copy Markdown
Contributor

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? 🤔

@javierlimt6

javierlimt6 commented May 28, 2026

Copy link
Copy Markdown
Author

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

@javierlimt6

Copy link
Copy Markdown
Author

@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

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