[Speculative Decoding] Validate vocabulary compatibility in STANDALONE mode#23838
Conversation
…coding
STANDALONE requires the draft model to share the same vocabulary as the
target model, but previously silently accepted any draft model, producing
lossy outputs when vocabularies differed.
Changes:
- Extract a static _validate_vocab_compatibility() method on
StandaloneWorker that performs two checks:
1. vocab_size must match (fast necessary check).
2. Full get_vocab() dicts must match (catches same-size but
different-content tokenizers, e.g. different tokenizer families
that happen to be the same size). Either tokenizer being None
(--skip-tokenizer-init) skips the deep check.
Both error messages point users to --speculative-algorithm TLI for
cross-family pairs.
- Add test/registered/unit/spec/test_standalone_vocab_check.py: 8 CPU-
only unit tests covering identical vocab, size mismatch, same-size
mapping mismatch, None tokenizer handling, and error message content.
There was a problem hiding this comment.
Code Review
This pull request introduces vocabulary compatibility validation for standalone speculative decoding, ensuring that draft and target models share identical token-to-ID mappings. The implementation adds a validation check during StandaloneWorker initialization, updates documentation to reflect this requirement, and includes unit tests. Feedback suggests that using get_vocab() for full vocabulary comparison may be computationally expensive for large models and could lead to an AttributeError if the method is not supported by all tokenizer types.
…tibility - Add hasattr(tokenizer, 'get_vocab') guards before calling get_vocab() to avoid AttributeError on tokenizer types that do not implement the method (e.g. TiktokenTokenizer). - Update docstring to mention TiktokenTokenizer and document the measured O(vocab_size) cost (~300 ms on a 256 k-token vocabulary, negligible at startup). - Add test_tokenizer_without_get_vocab_skips_mapping_check to cover this case.
|
Thanks for the review! I'd like to suggest keeping these as two PRs, if you're open to it. #23838 is a small, self-contained safety fix that prevents silent output corruption today on main, independent of TLI. The one valid coupling is the error message, which currently points users to --speculative-algorithm TLI. I'm happy to soften that wording so #23838 doesn't reference TLI by name — e.g.: STANDALONE speculative decoding requires the draft model to share the same vocabulary as the target model, but got target vocab_size=… and draft vocab_size=…. Use a draft model with a matching vocabulary, or a speculative algorithm that supports heterogeneous vocabularies. Then once #22883 lands, I can do a one-line follow-up to add the TLI hint back. Happy to fold them if you'd still prefer a single PR — just wanted to flag the trade-off. Let me know which you'd like. |
Per review feedback on sgl-project#23838, move the documentation note describing the vocab requirement (and the pointer to TLI) out of this PR. It will land in sgl-project#22883 alongside TLI itself. This PR keeps only the startup validation and error message.
Per review feedback on sgl-project#23838, StandaloneWorker owns the draft side (draft_tokenizer and draft_vocab_size), so reading them through self is clearer than threading them through the call site. Target side is still passed in since it lives on the target_worker. Test helper updated to pass a fake self carrying draft_model_runner. model_config.vocab_size and tokenizer; all 9 unit tests pass.
Adds a startup-validation note above the STANDALONE example so users who hit the vocab-mismatch ValueError have an in-doc explanation and a pointer to TLI for cross-family pairs. Originally proposed in PR sgl-project#23838 and moved here per review feedback. Co-Authored-By: Claude Opus 4.7 <[email protected]>
Ah no I still want to keep it as two separate PRs. I just mean to avoid referring to TLI in this PR. The new error message wording makes sense to me |
Thanks @kpham-sgl, that works! The docs note that mentioned TLI has been moved into TLI PR #22883 — see commit e4f0899. |
Address review feedback on sgl-project#23838 — replace the "--speculative-algorithm TLI instead" hint in both ValueErrors with neutral wording, and remove TLI assertions from the unit tests so this PR no longer references TLI by name.
0e62f2d to
29b1054
Compare
|
/tag-and-rerun-ci |
|
@kpham-sgl |
It does. Can you take a look at this failure? https://github.com/sgl-project/sglang/actions/runs/26471486639/job/77946051688?pr=23838 FYI we recently update our CI definition. You can point your agent to the test writing skills |
… test The 'stage-' prefix is reserved for AMD/NPU suites. CPU per-commit suites are 'base-a-test-cpu' / 'base-b-test-cpu', so registering to 'stage-a-test-cpu' caused run_suite.py validation to fail with 'Tests registered to invalid suites'.
|
/tag-and-rerun-ci |
|
/rerun-failed-ci |
1 similar comment
|
/rerun-failed-ci |
|
/tag-and-rerun-ci extra |
|
@kpham-sgl Missing required label 'run-ci-extra'. Add the label (e.g. via /tag-and-rerun-ci extra) to opt this PR into the extra test workflow. I tried /tag-and-rerun-ci extra myself but the label wasn't applied (looks like the slash command requires maintainer permissions). Could one of you add the run-ci-extra label so the extra test workflow can run? Thanks |
|
/rerun-failed-ci |
standalone_worker.py was deleted upstream (Spec V1 deprecated in sgl-project#25464). Port _validate_vocab_compatibility to StandaloneWorkerV2 in standalone_worker_v2.py and update the test accordingly.
|
/rerun-failed-ci |
|
Rechecked this after #24051 was auto-closed by the inactivity bot. On current head
One small validation note: my first container run accidentally imported the image preinstalled SGLang package rather than the PR checkout, which reproduced CI status also looks consistent with this: lint plus Arm64/Xeon build-test are green, and the upstream Base matrix includes successful H100/H200/B200 jobs on this head. The remaining visible red Extra gates are missing |
Drop test/registered/unit/spec/test_standalone_vocab_check.py. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
Thanks for the re-validation @ronhuafeng! Good to know everything passes cleanly on current head. |
Sorry I have been too busy recently... Merging this in soon |
|
/rerun-test test/registered/spec/test_spec_standalone.py test/registered/spec/test_spec_standalone_extra.py |
|
Results for 🚀 |
…E mode (sgl-project#23838) Co-authored-by: kpham-sgl <[email protected]> Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
…E mode (sgl-project#23838) Co-authored-by: kpham-sgl <[email protected]> Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
…E mode (sgl-project#23838) Co-authored-by: kpham-sgl <[email protected]> Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Motivation
STANDALONEspeculative decoding requires the draft model to share the same vocabulary as the target model. If the vocabularies differ, draft token IDs map to different strings in the target vocabulary, making the speculative decoding lossy — the output distribution no longer matches what the target model would produce alone. Previously, SGLang silently accepted any draft model with no error or warning.Concrete example with real models (
google/gemma-2-9bas target,bigcode/tiny_starcoder_pyas draft):The STANDALONE token-ID data flow is: (1) draft forward pass → token IDs, (2) target verifies those IDs, (3) target decodes accepted IDs into the final string. If the two tokenizers map the same integer to different strings, every accepted draft token is silently wrong.
Without the fix (
mainbranch)With the fix (
standalone-vocab-checkbranch)Modifications
python/sglang/srt/speculative/standalone_worker.pyAdds
StandaloneWorker._validate_vocab_compatibility(), a static method called at the end of__init__(after both models are loaded). It raisesValueErrorin two cases:vocab_sizemismatch — fast necessary check; catches obvious cross-family pairs (e.g. Llama 128k vs. Qwen 152k).get_vocab()dict mismatch — catches the subtler case where both models have the samevocab_sizebut different token-to-ID mappings (e.g. different tokenizer families that happen to be the same size). Skipped when either tokenizer isNone(--skip-tokenizer-init). This is the same approach used by HuggingFace Transformers (PR #35029) to distinguish homogeneous from heterogeneous vocabulary pairs.Both error messages point users to
--speculative-algorithm TLIfor cross-family pairs (see companion PR #22883).test/registered/unit/spec/test_standalone_vocab_check.py8 CPU-only unit tests (no server, no GPU) registered in
stage-a-test-cpu:test_identical_vocab_passestest_mismatched_vocab_size_raisesValueErrorwith sizes in messagetest_same_size_different_mapping_raisesValueErrortest_none_target_tokenizer_skips_mapping_checkNonetarget tokenizer → only size checkedtest_none_draft_tokenizer_skips_mapping_checkNonedraft tokenizer → only size checkedtest_both_tokenizers_none_skips_mapping_checkNone→ only size checkedtest_error_message_contains_vocab_sizestest_error_message_suggests_tli_for_size_mismatchTLINo behaviour change for existing valid STANDALONE configurations (same-family, same-vocab pairs).
Accuracy Tests
N/A — this change only adds a startup validation; it does not modify any model forward pass or token selection logic.
Speed Tests and Profiling
N/A — the validation runs once at server startup and has no impact on inference throughput.
Checklist
CI States
Latest PR Test (Base): ✅ Run #28356370156
Latest PR Test (Extra): ❌ Run #28356369823