Repository navigation
[Bugfix][Core] Uncount stop-trimmed spec tokens from num_computed_tokens - #58668
Conversation
There was a problem hiding this comment.
Good catch on this bug! LGTM
A good way understand imo is that, when spec decode requests are retired via update_from_outputs, we need to adjust num_computed_tokens and num_output_placeholders (decrementing both by num_rejected).
vllm/vllm/v1/core/sched/scheduler.py
Lines 2054 to 2066 in 7871963
However, when spec decode tokens are "trimmed" due to a STOP condition, this was not done. Leading to the issues above. Effectively, we need to
trimmed = num_new_tokens - retained
# discard trimmed positions, was missing!
num_computed_tokens -= trimmed
num_output_placeholders -= trimmed
# resolve retained output placeholders
num_output_placeholders -= retained
As an optimization, to avoid decrementing num_output_placeholders twice (once by retained, once by trimmed, this PR
num_computed_tokens -= trimmed
num_output_placeholders -= num_new_tokens
NIT1: Not sure the logprob comparison GPU test is necessary in CI
NIT2: Perhaps we should assert on num_computed_tokens being equal to len(all_token_ids) when resumable requests stop (after the _update_request_as_session but before appending a new streaming input chunk)
|
Thanks for the review and the clear write-up of the accounting. NIT1: agreed, the scheduler test already catches this. I'll drop the e2e test and keep its GPU result in the description. NIT2: The idea makes sense, but it only holds once both this PR and #58259 are in. |
|
This pull request has merge conflicts that must be resolved before it can be |
2da7e17 to
7726e20
Compare
When a speculative decode step stops on an accepted draft, the step's later tokens are trimmed but num_computed_tokens kept counting them. A resumable (streaming-input) request then folds the next chunk at that boundary, so the chunk's first tokens are treated as computed over KV that belongs to the trimmed drafts. Uncount the trimmed tokens so num_computed_tokens again covers all new tokens but the last kept one. Under async scheduling, also release the trimmed tokens' output placeholders, keeping num_computed_tokens - num_output_placeholders unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: David Cheung <d7cheung@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: David Cheung <d7cheung@gmail.com>
Both PRs added an identical helper at the tail of test_scheduler_streaming.py (vllm-project#58668 as _sampled, vllm-project#58778 as _model_output); settle on _model_output. Signed-off-by: David Cheung <d7cheung@gmail.com>
7726e20 to
634b148
Compare
Adopts the approach from vllm-project#59501: - Don't enqueue in _handle_stopped_request; update_from_output removes stopped requests from their queues and enqueues unfinished ones exactly once, so a request is never in two queues at once. - Fold at num_tokens - 1 directly instead of max(num_computed_tokens, num_tokens - 1); after vllm-project#58668 the two coincide in the normal autoregressive case, and the new assert guards that invariant. - Reject resumable requests for encoder-only and diffusion models, which do not satisfy the invariant. Existing fold tests now append a sampled token so the session state satisfies the fold invariant, and the upstream empty-stream test sets the model-type flags exercised by the new validation. Co-authored-by: Yifan Zong <yzong@redhat.com> Signed-off-by: David Cheung <d7cheung@gmail.com>
|
This pull request has merge conflicts that must be resolved before it can be |
# Conflicts: # tests/v1/streaming_input/test_scheduler_streaming.py Signed-off-by: Nick Hill <nickhill123@gmail.com>
|
/ci run |
|
✅ Triggered Buildkite CI #95177 for commit |
|
✅ @sawsa307, CI is now available for this PR.
|
Adopts the approach from vllm-project#59501: - Don't enqueue in _handle_stopped_request; update_from_output removes stopped requests from their queues and enqueues unfinished ones exactly once, so a request is never in two queues at once. - Fold at num_tokens - 1 directly instead of max(num_computed_tokens, num_tokens - 1); after vllm-project#58668 the two coincide in the normal autoregressive case, and the new assert guards that invariant. - Reject resumable requests for encoder-only and diffusion models, which do not satisfy the invariant. Existing fold tests now append a sampled token so the session state satisfies the fold invariant, and the upstream empty-stream test sets the model-type flags exercised by the new validation. Co-authored-by: Yifan Zong <yzong@redhat.com> Signed-off-by: David Cheung <d7cheung@gmail.com>
…ens (vllm-project#58668) Signed-off-by: David Cheung <d7cheung@gmail.com> Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Nick Hill <nickhill123@gmail.com>
### What this PR does / why we need it?
Port `vllm-ascend` to vLLM main `c0b6f74` (bumped from `a98247a`) while
keeping the
`v0.31.0` release lane green. The upstream window moved the KV-cache
planning
helpers into a new module, made EAGLE draft groups explicit, typed
DeepSeek-V4
configs through upstream Transformers, removed Medusa/MLP speculator
support, and
changed several spec-decode/KV-cache/EPLB signatures. Every gate below
is either a
`vllm_version_is("0.31.0")` split or an attribute/argument probe where
two main dev
builds share one version string.
| Files | Upstream vLLM change (why it breaks vllm-ascend) | vllm-ascend
adaptation |
|-------|--------------------------------------------------|------------------------|
| `.github/vllm-main-verified.commit` | — (main-lane pin only) |
Verified main commit bumped `a98247a` -> `c0b6f74` |
| `tests/e2e/pull_request/four_card/test_minimax_m3.py` | The main-lane
pin re-JITs the static kernels, so the first generate on a cold cache
shifts the near-uniform dummy logprobs (~3e-3) enough to flip a greedy
token | On main run a throwaway `generate_greedy_logprobs` before the
baseline/replay pair so both measure the warmed steady state and share
the cached prefix; v0.31.0 keeps the single-shot comparison |
| `tests/ut/core/test_circular_buffer.py` |
[vllm#61126](vllm-project/vllm#61126)
(cascade-attention removal) moved the prefix-block query up to
`KVCacheManager` and dropped it from single-type managers | On main
assert only the manager's own `get_num_skipped_tokens(65536)`; keep the
combined `get_num_common_prefix_blocks(...)` assert on v0.31.0 |
| `tests/ut/core/test_kv_cache_interface.py` |
[vllm#61183](vllm-project/vllm#61183) moved
`is_kv_cache_spec_uniform` into `vllm.v1.core.kv_cache_planning` |
Version-aware import (`kv_cache_utils` on v0.31.0 / `kv_cache_planning`
on main) |
| `tests/ut/distributed/ascend_store/_mock_deps.py` |
[vllm#61183](vllm-project/vllm#61183) added the
`vllm.v1.core.kv_cache_planning` module | Add it to the mocked-vLLM
module list |
| `tests/ut/distributed/ascend_store/test_request_lifecycle.py` |
[vllm#58668](vllm-project/vllm#58668) trims
`num_computed_tokens` to the tokens actually kept when a request stops
mid token-list | On main subtract `(accepted + 1) - <newly appended
output tokens>` from the expected computed count; keep the old
expectation on v0.31.0 |
| `tests/ut/distributed/kv_transfer/kv_p2p/test_mooncake_connector.py` |
[vllm#61183](vllm-project/vllm#61183) moved
`get_kv_cache_config_from_groups` / `is_kv_cache_spec_uniform` |
Version-aware import |
| `tests/ut/models/test_deepseek_v41_cache.py` |
[vllm#61183](vllm-project/vllm#61183) relocated
the planning helpers;
[vllm#53558](vllm-project/vllm#53558) gave
`_max_memory_usage_bytes_from_groups` a leading `builder` argument |
Import from the live planning module and probe the upstream signature to
call the 2-arg (v0.31.0) vs 3-arg (main) form, feeding an Ascend
`_get_pool_bytes_per_block` builder |
| `tests/ut/ops/test_gdn_attn_builder.py` |
[vllm#58737](vllm-project/vllm#58737) derives
`num_reqs` from the block table and asserts its row count equals
`seq_lens`; Ascend's `unpadded()` keeps the graph-padded rows | New UT:
a padded 4-row block table with 2 real requests must still yield a 2-row
`cache_indices` |
| `tests/ut/patch/platform/test_packed_kv_cache_compat.py` |
[vllm#61183](vllm-project/vllm#61183) relocated
the patched module;
[vllm#61455](vllm-project/vllm#61455) removed
`_annotate_eagle_groups` from main | Patch
`cache_patch._kv_cache_planning`; new UT asserts the packed hook
tolerates an upstream module without the EAGLE-annotation API |
| `tests/ut/patch/platform/test_patch_eplb.py` |
[vllm#57549](vllm-project/vllm#57549) emits the
async EPLB cycle-committed log from the async worker wrapper on main |
Expect the workspace-move log only on v0.31.0 |
| `tests/ut/patch/platform/test_patch_kv_cache_coordinator.py` |
[vllm#61183](vllm-project/vllm#61183) relocated
the patch entry point | Assert against `kcu_patch._kv_cache_planning`
instead of `vllm.v1.core.kv_cache_utils` |
| `tests/ut/patch/platform/test_patch_pp_mtp.py` |
[vllm#56322](vllm-project/vllm#56322) makes
`ModelConfig.verify_with_parallel_config` read
`parallel_config.enable_hc_sp` | Add `enable_hc_sp=False` to the
constructed parallel config (ignored on v0.31.0) |
| `tests/ut/patch/platform/test_prefix_cache_cp_patches.py` |
[vllm#60043](vllm-project/vllm#60043) removed
the `mamba_cache_mode="all"` leftover and requires `"align"` with prefix
caching; [vllm#53558](vllm-project/vllm#53558)
added the `builder` argument | Version-select the mamba cache mode;
probe the upstream admission signature for the `builder` argument |
| `tests/ut/spec_decode/test_mla_dspark.py` |
[vllm#60692](vllm-project/vllm#60692) renamed
the capture hook `precompute_context_kv(num_reqs)` ->
`prepare_forward(num_reqs, attn_metadata)` | Version-split the expected
capture args/kwargs |
| `tests/ut/test_compressed_prefix_cache.py` |
[vllm#61183](vllm-project/vllm#61183) moved
`is_kv_cache_spec_uniform` | Version-aware import |
| `tests/ut/worker/test_attn_utils_v2.py` |
[vllm#61455](vllm-project/vllm#61455) annotates
EAGLE groups from `spec.is_draft`;
[vllm#60766](vllm-project/vllm#60766) resolves
pin memory lazily via `is_pin_memory_available()` | New UT that
draft-layer specs get tagged; on main monkeypatch
`is_pin_memory_available` to `False` |
| `tests/ut/worker/test_model_runner_v2.py` |
[vllm#56716](vllm-project/vllm#56716) suppresses
EPLB even for `skip_eplb` dummy runs;
[vllm#60946](vllm-project/vllm#60946) renamed
the PCP staging hook | Add a `runner.eplb` stub; call the
version-appropriate staging method |
| `tests/ut/worker/test_mtp_pcp_speculator_v2.py` |
[vllm#60885](vllm-project/vllm#60885) rejects a
mismatched explicit float16/bfloat16 KV-cache dtype | Build the model
config with `bfloat16` instead of `float32` |
| `tests/ut/worker/test_pcp_manager_v2.py` |
[vllm#60946](vllm-project/vllm#60946) renamed
`prepare_inputs_to_capture` -> `prepare_inputs_for_capture_or_profile` |
Patch and call the live method name per lane |
| `tests/ut/worker/test_v2_dummy_padding.py` |
[vllm#61767](vllm-project/vllm#61767) collects
DP votes through `get_dp_group().cpu_all_reduce_()` instead of
`dist.all_reduce(..., group=cpu_group)` | Version-split the mocked
collective |
| `tests/ut/worker/v2/test_eplb_controller.py` |
[vllm#57549](vllm-project/vllm#57549) gives
`add_model` an optional `model_name` and names the draft entry | Expect
the 3-arg call (`model_name="draft (draft)"`) on main, 2-arg on v0.31.0
|
| `vllm_ascend/_310p/worker_310p.py` |
[vllm#61183](vllm-project/vllm#61183) moved
`get_kv_cache_groups` | Version-aware import |
| `vllm_ascend/distributed/ec_transfer/ec_memcache_connector.py` |
[vllm#57904](vllm-project/vllm#57904) dropped
`_get_encoder_cache_hidden_dim` from `cpu.common` (encoder widths are
measured at startup) | On main size the receive buffer as
`out_hidden_size * (1 + len(deepstack_visual_indexes))` (Qwen3-VL
DeepStack) else `get_inputs_embeds_size()`; keep the helper on v0.31.0 |
| `vllm_ascend/models/deepseek_v4/compressor.py` |
[vllm#61392](vllm-project/vllm#61392) switched
`DeepseekV4Config` to the upstream Transformers class | Import from
`transformers` on main, from
`vllm.transformers_utils.configs.deepseek_v4` on v0.31.0 |
| `vllm_ascend/models/deepseek_v4/indexer.py` | Same
[vllm#61392](vllm-project/vllm#61392) | Same
version-aware import |
| `vllm_ascend/models/deepseek_v4/mm_preprocess.py` | Same
[vllm#61392](vllm-project/vllm#61392) | Same
version-aware import |
| `vllm_ascend/models/deepseek_v4/model.py` |
[vllm#61392](vllm-project/vllm#61392) moves the
config to Transformers, folds `compress_ratios`/`num_hash_layers` into
`layer_types`/`mlp_layer_types`, and keys YaRN under the compressor
(`compress`) group | Restore the legacy fields on main from the new
lists, read the `compress`-scoped rope params with safe defaults, and
keep the legacy path on v0.31.0 |
| `vllm_ascend/models/deepseek_v41/cache_config.py` |
[vllm#61183](vllm-project/vllm#61183) moved
`may_override_num_blocks` | Version-aware import |
| `vllm_ascend/models/glm5next/cache_config.py` |
[vllm#61183](vllm-project/vllm#61183) moved
`create_kv_cache_group_specs` / `may_override_num_blocks` |
Version-aware import |
| `vllm_ascend/ops/gdn_attn_builder.py` |
[vllm#58737](vllm-project/vllm#58737) derives
`num_reqs` from the block table and asserts row parity with `seq_lens` |
On main trim the padded block table to `seq_lens.shape[0]` before
`mamba_get_block_table_tensor` when the sizes differ |
| `vllm_ascend/patch/__init__.py` |
[vllm#61183](vllm-project/vllm#61183) moved
`get_kv_cache_config_from_groups` to `kv_cache_planning` | Update the
patch-registry doc |
| `vllm_ascend/patch/platform/__init__.py` |
[vllm#61183](vllm-project/vllm#61183) moved the
KV-cache planning/patcher module | Update the patch-registry doc |
| `vllm_ascend/patch/platform/patch_eplb.py` |
[vllm#57549](vllm-project/vllm#57549) moves the
async EPLB cycle-committed log onto the async worker path on main |
Report completion from the wrapped async worker (rank 0) on main; keep
the workspace-move log on v0.31.0 |
| `vllm_ascend/patch/platform/patch_kv_cache_utils.py` |
[vllm#61183](vllm-project/vllm#61183) moved the
planning helpers;
[vllm#53558](vllm-project/vllm#53558) added the
`builder` argument;
[vllm#61455](vllm-project/vllm#61455) dropped
`_annotate_eagle_groups` | Resolve the live planning module by version,
patch its symbols, gate the EAGLE annotation on attribute presence, and
dispatch the admission helper by argument count |
| `vllm_ascend/quantization/methods/kv_cache/turboquant/cache.py` |
[vllm#61183](vllm-project/vllm#61183) moved
`may_override_num_blocks` | Version-aware import |
| `vllm_ascend/spec_decode/__init__.py` |
[vllm#61701](vllm-project/vllm#61701) removed
Medusa/MLP speculator support | Import `AscendMedusaProposer` only on
v0.31.0; raise a clear error for `method="medusa"` on main |
| `vllm_ascend/spec_decode/medusa_proposer.py` |
[vllm#61701](vllm-project/vllm#61701) removed
`vllm.v1.spec_decode.medusa` | Add `# type: ignore[import-not-found]`;
the module only loads on the lane that still ships it |
| `vllm_ascend/worker/v1/model_runner.py` | Same
[vllm#61701](vllm-project/vllm#61701) | Gate the
Medusa import and the `isinstance(self.drafter, AscendMedusaProposer)`
branch on v0.31.0 |
| `vllm_ascend/worker/v2/attn_utils.py` |
[vllm#61455](vllm-project/vllm#61455) annotates
EAGLE groups from `spec.is_draft` | On main tag the spec with `is_draft`
from `attn_module.is_draft_layer` |
| `vllm_ascend/worker/v2/pcp_manager.py` |
[vllm#60946](vllm-project/vllm#60946) added the
`moe_gathers_tokens` argument and renamed `prepare_inputs_to_capture` ->
`prepare_inputs_for_capture_or_profile` | Forward the new argument;
version-split the staging hook |
| `vllm_ascend/worker/v2/sample/apply_top_k_top_p.py` |
[vllm#61996](vllm-project/vllm#61996) added
`has_topp_only_rows` to the split top-p pipeline | Accept and ignore the
extra argument on main (the PyTorch fallback handles top-p-only rows) |
| `vllm_ascend/worker/v2/spec_decode/dflash/aclgraph.py` |
[vllm#60692](vllm-project/vllm#60692) renamed
`precompute_context_kv` -> `prepare_forward` | Pass the capture hook the
installed vLLM expects |
| `vllm_ascend/worker/v2/spec_decode/lmhead_tp_utils.py` |
[vllm#59554](vllm-project/vllm#59554) added
`num_steps` to `Speculator.sample_draft` | Version-split the override
signature and forward `num_steps` on main |
| `vllm_ascend/worker/v2/spec_decode/rejection_sampler_utils.py` |
[vllm#61451](vllm-project/vllm#61451) renamed
the logits-stats kernel argument `num_speculative_steps` -> `num_logits`
and moved bonus-token detection into `_is_bonus_logit` | Pass
`num_logits` on main; keep `num_speculative_steps` on v0.31.0 |
| `vllm_ascend/worker/worker.py` |
[vllm#61183](vllm-project/vllm#61183) moved
`get_kv_cache_groups`/`unify_hybrid_kv_cache_specs`;
[vllm#57516](vllm-project/vllm#57516) reclaims
cached memory via `torch.accelerator.empty_cache()` inside `load_model`
| Version-aware imports; on main neutralize `torch.npu.empty_cache`
while loading weights under the sleep-mode pluggable memory pool |
### Does this PR introduce _any_ user-facing change?
No. This is an internal main2main compatibility update; on the `v0.31.0`
release lane
behavior is unchanged, and on main the only behavioral note is that
Medusa
speculative decoding now raises a clear error (upstream removed it in
vllm#61701).
### How was this patch tested?
- Unit tests: the changed UT files above (KV-cache planning/coordinator,
EPLB,
PCP/spec-decode, DeepSeek-V4/V4.1 cache, mamba align,
`worker/worker.py`).
- E2E: `tests/e2e/pull_request/four_card/test_minimax_m3.py`.
- Run the main lane against the pinned `c0b6f74` and the `v0.31.0` lane
via
`vllm_version_is`.
---
### Checklist
#### Testing
- [ ] **Boundary / edge-case coverage**: I reviewed whether this change
involves
boundary conditions (e.g. empty input, single/zero element, min/max
values,
off-by-one, sequence-length limits, integer overflow). Where applicable,
I
added unit tests covering these edge cases in `tests/ut/` or
`tests/e2e/`.
- [ ] **Regression test**: If this PR fixes a bug, I added a regression
test
that would fail without the fix.
- [ ] **Error / failure path coverage**: I covered abnormal paths
(exceptions,
timeouts, out-of-range, division by zero) in addition to the happy path.
- [ ] **Meaningful & reliable tests**: New tests assert real behavior
and are
not written merely to satisfy CI.
- [ ] **Performance impact**: If this PR touches a performance-sensitive
path,
I attached benchmark results and added performance regression tests.
- vLLM main:
vllm-project/vllm@a98247a
Signed-off-by: zhangxinyuehfad <starmoon_zhang@163.com>
Purpose
When a speculative decode step stops on an accepted draft (EOS, a stop token, or
max_tokensreached before the bonus token),_update_request_with_outputtrims the step's later tokens, butnum_computed_tokenskeeps counting them. Rejections are rolled back inupdate_from_output; trimmed tokens are not.Ordinary requests are freed right after the stop, so the count is never read again. A resumable (streaming-input) request continues:
_update_request_as_sessionusesnum_computed_tokensas the fold boundary, so the next chunk starts "already computed" over positions whose KV belongs to the trimmed drafts.Prompt
[1, 2, 3],max_tokens=2, k=2. The prefill samples10; a step with drafts[11, 12]returns[11, 12, 13](both accepted) and stops at11. Folding in the next chunk[7, 8]:all_token_idsnum_computed_tokens[1, 2, 3, 10, 11, 7, 8]8only;7reuses draft12's KV[1, 2, 3, 10, 7, 8]7, 8The fix uncounts the trimmed tokens, so
num_computed_tokensagain covers all new tokens but the last kept one. The fold then drops that token exactly as it drops the final sampled token of a non-speculative turn, which is what makes a session's output identical with and without speculative decoding (see the GPU check below).Under async scheduling the trimmed tokens' placeholders were never released either (
num_output_placeholderswas decremented by the post-trim count), so they are released too.num_computed_tokens - num_output_placeholdersis unchanged, so neither the asynccache_blocksfrontier nor theC - Pfrontier from #58259 moves.This count is also what stale block-hash truncation keys on (#52806, and the
del session.block_hashes[num_computed_tokens // hash_block_size:]suggested on #58259): with the overshoot, the cut point lands too late and leaves stale hashes behind.Diffusion schedulers inherit the change; resumable diffusion requests were not exercised.
Not a duplicate
num_computed_tokenstonum_prompt_tokensafter the fold. That catches only an overshoot past the whole new chunk (num_new_tokens <= 0); in the example above it is a no-op and7is still skipped. This PR fixes the count where it goes wrong.C - Pfrontier also covers this case under async scheduling, but not under sync scheduling, wherenum_output_placeholdersis always 0. This PR keepsC - Punchanged, so the two compose.cache_blockscall atrequest.num_tokensto work around the same overshoot; the cap stays correct.num_computed_tokenstrimming, and_update_request_as_session; no other PR fixes this.Test Plan
New test:
test_next_chunk_fully_computed_after_stop_on_accepted_draft(scheduler, sync and async scheduling): after the trimmed stop, the fold drops the last kept token and the next chunk is scheduled in full.GPU check, run manually and not kept in CI: a 6-chunk streaming session (opt-125m, fp32 + FLEX_ATTENTION, greedy,
max_tokens=2per chunk) must produce the same tokens and logprobs with and without ngram speculative decoding (k=4), with drafts accepted. The greedy tokens of this repetitive prompt do not change with the context, so logprobs are what catch the difference.Test Result
C/Paccounting still composes with it.test_scheduler_streaming.py+test_scheduler.py+test_async_scheduler.py: 221 passed.tests/v1/core+tests/v1/streaming_input(CPU): 797 passed. The 1 failure and 6 errors (test_reset_prefix_cache_e2e,test_scheduler_e2e) need a GPU and are identical on main.[-0.3593, -0.5455]with ngram vs[-0.3427, -0.7063]without.tests/v1/e2e/general/test_streaming_input.pypasses.tests/v1/e2e/general/test_async_scheduling.py::test_with_ngram_gpu_spec_decodingpasses, all 6 configs (sync/async, preemption, chunked prefill, mp/uni executor) match the non-speculative baseline in outputs and logprobs.ngramis not yet supported by Model Runner V2, so the streaming session check uses the V1 model runner. The fix is in the scheduler and runner-independent. Async scheduling is covered by the scheduler test only.AI assistance (Claude Code) was used to find, reproduce and fix this. I reviewed every changed line and ran the tests above.
Essential Elements of an Effective PR Description Checklist
supported_models.mdandexamplesfor a new model.🤖 Generated with Claude Code