Repository navigation
[Perf][Sched] Multi-step merged decode for host-bound AR stages - #7064
Green-Vial wants to merge 12 commits into
Conversation
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
|
This PR appears to be related to model: minicpm. Model owners: @y-null @Green-Vial, please review your own changes and leave a short self-review comment describing what you checked. PRs without author self-review may not be assigned a reviewer. Please take a look when you have a chance. If you would like an automated review, mention @vllm-omni-review-bot in a comment. |
Omni ReviewBot triage noteAutomated triage of commit
These are automated triage suggestions only — the final decision belongs to the maintainers. |
2caef81 to
e5b6ff6
Compare
|
Self-review notes:
|
|
After submitting the PR, I reviewed it again and noticed an issue. I’m working on a fix and will push an update once it’s ready. |
53f7799 to
97081b5
Compare
Have fixed! |
97081b5 to
622d5c0
Compare
… step on async scheduling Small autoregressive stages (the MiniCPM-o 4.5 Talker) spend more host time per decode step (scheduling pass, input preparation, attention metadata, sampling bookkeeping, inter-stage IPC) than they would like to spend on the forward itself. This lets one engine step cover K decode steps for the whole batch, amortizing the per-step host cost over K tokens. Scheduler side (vllm_omni/core/sched/multi_step_decode.py): - after the base schedule() produced a plain single-token decode step, bump num_scheduled_tokens to K for the whole batch, allocate the K-1 extra KV slots, and inflate num_output_placeholders/num_computed_tokens so the async accounting closes exactly when the runner reports the K sampled tokens; reconcile_window_shortfall rolls back the reservations of steps that never ran (early exit or fallback); - fail-closed planning: phase/batch eligibility (_request_admits_multi_step) is checked here with authoritative state, admission is fenced while the waiting queue is non-empty, and platforms/parallelism/features that the runner cannot reproduce bit-identically are refused. Runner side (vllm_omni/platforms/npu/worker/multi_step_decode.py): - execute_multi_step_window replays the K decode steps inside one execute_model: per-step state advance, attention metadata, request-local preprocess, forward, sampler and bookkeeping -- mirroring the single-step path exactly so outputs stay bit-identical; - validate_multi_step_plan re-checks only static configuration: per-step mutable state is deliberately NOT re-checked (the runner's input_batch view lags the scheduler under async scheduling, and a refusal after the scheduler planned follow-up windows on top of this one is unrecoverable -- the follow-up plans' KV positions would skip this window's reserved slots, leaving holes of uninitialized cache); - a residual (static) refusal shrinks the schedule back to one token per request via shrink_refused_multi_step_window so the fallback stays bit-identical to a never-planned step; - uniform K-token windows also handle FULL-graph padding, early exit when every request finished, and window-end output assembly for the engine. Model contract: the Talker declares supports_multi_step_stages; its preprocess() rebuilds every decode embedding from the request-local codec chain and make_omni_output() advances that state in place, reporting the terminal flag for already-finished requests so follow-up windows early-exit on the first dead step instead of burning K-1 steps. Verification (910B3, seed-tts zh/en c1): - talker codec stream byte-identical to the non-windowed baseline; - Mean WER identical (2.81% vs 2.81%, 32/32 evaluated); - EN c1x32: E2EL -4.3% (3409->3261ms), AUDIO_RTF -4.8% (0.84->0.80). Signed-off-by: Green-Vial <Green-Vial@users.noreply.github.com>
…indows Steps 1..K-1 of a multi-step decode window only vary per step in the sequence lengths: block tables and slot mappings are live views of the runner's buffers (compute_slot_mapping refreshes them in place), the query layout is frozen for the window, and the FIA attention backend consumes seq_lens / seq_lens_list per layer. Rebuilding the full per-layer attention metadata every step re-derives all of the step-invariant fields at a measurable host cost (~0.4ms/step on the talker stage, measured via the MSD step timing instrumentation). Derive steps 1..K-1 from the step-0 build instead: shallow copies of each per-layer metadata with only the seq-len fields refreshed to base_lens + j. Step 0 keeps the original builder path, so the output is bit-identical to the pre-optimization window path; any prebuild failure falls back to per-step builds (pure optimization, never a correctness dependency). Measured on 910B3, Seed-TTS zh c1 x32, K=8: window ms/token 14.951 -> 14.535 (-2.8%), E2EL -1.0%, audio stream byte-identical. Signed-off-by: Green-Vial <Green-Vial@users.noreply.github.com>
…rnal amortization Extends the multi-step window to the synchronous scheduler (one window in flight, direct accounting instead of the async placeholder fence) and adds window-internal amortization (WIA) that trims the per-step host work left inside the window loop: - Sync scheduling: plan_multi_step_window / reconcile_window_shortfall / the runner's step-0 input path now serve the sync OmniARScheduler (step-0 input comes from the request-local output token chain). - WIA cos prebuild (OMNI_MSD_COS_PREBUILD=0 to disable): fills the window's K*num_reqs rotary rows in one update_cos_sin and copies the per-step block per step (pure optimization, per-step fallback on any failure). - WIA engine-token derivation (OMNI_MSD_TOK_FROM_STATE=0 to disable): the talker's binary STOP/CONTINUE token is derived from the request-local state chain under greedy sampling, skipping the device sampler path; refuses on any uncertainty (non-greedy, logit bias, native duplex) and falls back to the exact single-step sampler. - Fixes folded in: min_tokens mask counted from the runner's request-local output length (sampling_metadata.output_token_ids is None on the window path, which kept the mask stuck and re-scheduled finished requests for ~1900 dead windows under sync); derived tokens are emitted int32 to match the engine token layout (the async feedback is scattered into the int32 input_ids buffer; an int64 src crashed aclnnInplaceScatter on the batch-reorder path). - Env-gated stage1 per-step timing (OMNI_MSD_STEP_TIMING=1) for reproducible per-step / per-token accounting. Signed-off-by: Green-Vial <Green-Vial@users.noreply.github.com>
| return False | ||
| # Decode phase only: the window writes K contiguous post-prompt slots and | ||
| # cannot interleave with chunked prefill or prefix-cache (re)computation. | ||
| if request.num_computed_tokens < request.num_prompt_tokens: |
There was a problem hiding this comment.
Check the pre-schedule token count so the final prefill token cannot open a decode window.
There was a problem hiding this comment.
Fixed in d03aed3: the gate now compares the pre-schedule count (num_computed_tokens - scheduled_tokens ) againstnum_prompt_tokens , so the final prefill token — which leavesnum_computed_tokens == num_prompt_tokens after the base scheduler advances it — is refused instead of opening a decode window, with a unit test covering that boundary.
| payload["hidden"] = torch.cat(per_req_hidden[i], dim=0).to("cpu").contiguous() | ||
| if per_req_deltas[i]: | ||
| payload["codes.audio"] = torch.cat(per_req_deltas[i], dim=0).to("cpu") | ||
| payload["meta.finished"] = torch.tensor(bool(per_req_finished[i]), dtype=torch.bool) |
There was a problem hiding this comment.
Preserve duplex metadata in window outputs so Code2Wav does not prematurely close the stream.
There was a problem hiding this comment.
Fixed in d03aed3: the window loop now accumulatesnative_duplex /duplex_epoch /duplex_turn_id /llm_output_text_utf8 /turn_end (turn_end OR-accumulated so a turn boundary inside the window is never lost) and ships them under the same flatmeta.* keys as the single-step path, leaving non-duplex payloads byte-identical.
…duplex metadata Two correctness fixes found in review of the multi-step decode window: - Gate the window on the PRE-schedule token count. The base scheduler already advanced num_computed_tokens by the tokens it scheduled for this step (_update_after_schedule runs inside super().schedule()), so the final prefill token -- also scheduled as a single token -- left num_computed_tokens == num_prompt_tokens and passed the old "computed < prompt" check, opening a decode window on a prefill position. _request_admits_multi_step now takes the scheduled token count and compares num_computed_tokens - scheduled_tokens against num_prompt_tokens. - Preserve duplex metadata in window outputs. The K-step loop kept only meta.finished and dropped native_duplex / duplex_epoch / duplex_turn_id / llm_output_text_utf8 / turn_end, which the single-step path emits for native-duplex requests. Code2Wav's async-chunk processor reads turn_end to decide stream closure, so a windowed duplex request was treated as non-duplex and the stream could close prematurely. The window now accumulates the five fields per request (turn_end OR-accumulated so a turn boundary inside the window is never lost) and _build_window_output ships them under the same flat meta.* keys as the single-step path. Non-duplex requests are unaffected: no duplex field is emitted, so the window payload is byte-identical to before. Adds test_plan_window_refused_for_final_prefill_token covering the computed == prompt boundary and the computed == prompt + 1 decode case. Verified on 910B3 Seed-TTS EN c1x32 K=12 sync: E2EL 3006.6 ms / RTF 0.7320 vs the K=12 sweep baseline 2990.9 ms / 0.7284 (within run-to-run noise), audio duration 134.92 s identical, window counters 251/3012 identical. Signed-off-by: Green-Vial <Green-Vial@users.noreply.github.com>
Omni ReviewBot: no human activity for 12 days@Green-Vial this pull request has had no human commit, comment or review since 2026-09-16. Please confirm the current plan and next step. The author or a maintainer decides whether to change the PR state. To keep it moving, any one of these is enough: push an update, reply to the open blocker, or post the current plan and timeline. |
Omni ReviewBot routing recordAssigned Strict on cursor (cursor-grok-4.6-high) under experiment |
vllm-omni-review-bot
left a comment
There was a problem hiding this comment.
Omni ReviewBot review
7 actionable finding(s).
CI at
d03aed315bdb(2026-10-09T23:05:01.293174+00:00): no required or operator-watched checks were configured.
Full review analysis
Scan:
| Category | Result |
|---|---|
| Tests / verification | 5 finding(s) below |
| Security | no finding reported |
| Docs / comments | 1 finding(s) below |
| Behavior / compatibility | no finding reported |
| Correctness | 1 finding(s) below |
Validated:
- [resolved] prefill gate (d03aed3 / lishunyang12): _request_admits_multi_step uses num_computed_tokens - scheduled_tokens (multi_step_decode.py:220); test_plan_window_refused_for_final_prefill_token covers computed==prompt. Residual: mixed-batch still refuse-all, not per-request
- [resolved] duplex meta shipped under meta.*; turn_end OR-accumulated; residual: non-turn_end keys last-step-win.
- [resolved] pipeline.py:57 model_stage="tts" matches supports_multi_step_stages = ("tts",) — off-fixture vs the PR test's WRAPPER="tts".
- [claim-verified] 42 test_* functions in the two new files (PR said 41).
- [claim-verified] table arithmetic: 3230.6->2990.9 = -7.42%; 9.44->7.50 = -20.55%; 13178.5->10455.2 = -20.66%. Measurement JSON not in-repo.
- [claim-verified] wrap-then-plan order: _wrap_omni_scheduler_output (omni_scheduler_mixin.py:178) does not copy multi_step_plan; plan mutates the wrapped output afterward (omni_ar_scheduler.py:285-297)
7 actionable finding(s).
Verdict: REQUEST CHANGES
Findings
- [P1] Accepted windows return from execute_model at npu_ar_model_runner.py:506 before… —
vllm_omni/platforms/npu/worker/npu_ar_model_runner.py
Evidence for Accepted windows return from execute_model at npu_ar_model_runner.py:506 before…
Accepted windows return from execute_model at npu_ar_model_runner.py:506 before the existing on_requests_finished call at :522-523. execute_multi_step_window only runs _update_states (platforms/npu/worker/multi_step_decode.py:426) and never the model hook; plan_multi_step_window also does not inspect finished_req_ids, so waiting/new-req gates still allow a uniform decode of remaining siblings. MiniCPMO45OmniTTSForConditionalGeneration.on_requests_finished queues ids that _flush_deferred_cleanup pops from _request_audio_states/_request_generators at the next forward(); the omni wrapper also drops duplex sessions. Those finished_req_ids are one-shot on that SchedulerOutput, so a talker that finishes while siblings keep windowing never has its per-request state queued. Call the same hook immediately before execute_multi_step_window (IDs only; flush still happens at forward start).
Evidence: Trigger: concurrent MiniCPM-o talker decode (c>1) where request A finishes and the next schedule still opens a window for remaining siblings — plan_multi_step_window never inspects finished_req_ids (if scheduler_output.scheduled_new_reqs: return False only). Adverse effect: A's TTS _request_audio_states/_request_generators (and wrapper duplex sessions) are never queued for cleanup because those IDs are one-shot on that SchedulerOutput. npu_ar_model_runner.py:498 execute_multi_step_window(self, scheduler_output, multi_step_plan) then :506 return None — skips the unchanged hook at :522-523 if scheduler_output.finished_req_ids and hasattr(self.model, "on_requests_finished"): / self.model.on_requests_finished(scheduler_output.finished_req_ids). platforms/npu/worker/multi_step_decode.py:426 deferred = runner._update_states(scheduler_output) is the window's only state update (no model hook). Unchanged by this diff, present in the PR-time tree: minicpmo_4_5_omni_tts.py:586-594 self._deferred_cleanup_ids.update(...) / _request_generators.pop / request_audio_states.pop; :628 self._flush_deferred_cleanup(); minicpmo_4_5_omni.py:648-663 wrapper on_requests_finished pops _minicpmo45_duplex_request_sessions then forwards to the inner model.
Suggestion: if scheduler_output.finished_req_ids and hasattr(self.model, "on_requests_finished"):
self.model.on_requests_finished(scheduler_output.finished_req_ids)
execute_multi_step_window(self, scheduler_output, multi_step_plan)
🤖 This review was generated by InferMatrix Copilot, an open-source repo-maintenance agent for PR review, CI debugging and issue triage. Try it on your own repo, and ⭐ star it if it helped!
| import numpy as np | ||
| import pytest | ||
|
|
||
| import vllm_omni.platforms.npu.worker.multi_step_decode as msd_module |
There was a problem hiding this comment.
[P0] New tests/worker/test_multi_step_decode_runner.py:17 collection-imports vllm_om…
Evidence and suggested fix
New tests/worker/test_multi_step_decode_runner.py:17 collection-imports vllm_omni.platforms.npu.worker.multi_step_decode, and that module top-level-imports vllm_ascend (multi_step_decode.py:60-62). Unchanged CUDA merge-gate .buildkite/cuda/test-ready.yml:18 (pytest -m 'core_model and cpu' --ignore=tests/diffusion --ignore=tests/model_executor) discovers tests/worker via pyproject testpaths=["tests"]; markers apply only after collection, so ImportError fails the whole Simple · Other Test job. Sibling tests/worker/test_native_duplex_hooks.py:2480-2481 already avoids importing the NPU runner because vllm_ascend is absent on those CPU images. Stub vllm_ascend in sys.modules before the import (same idea as tests/platforms/npu/test_310p_patches.py fakes) and set pytestmark = [pytest.mark.core_model, pytest.mark.cpu]. Do not importorskip('vllm_ascend') — that would skip these CPU-fake tests on the same lane.
Evidence: Trigger: CUDA merge-gate Simple · Other Test collects tests/worker. Unchanged by this diff, present in the PR-time tree: .buildkite/cuda/test-ready.yml:18 timeout 20m pytest -sv -m 'core_model and cpu' --ignore=tests/diffusion --ignore=tests/model_executor --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml; unchanged pyproject.toml:215 testpaths = ["tests"]. Collection imports tests/worker/test_multi_step_decode_runner.py:17 import vllm_omni.platforms.npu.worker.multi_step_decode as msd_module (no pytestmark; every other tests/worker file has pytestmark = [pytest.mark.core_model, pytest.mark.cpu]) before -m can deselect. That module unconditionally does vllm_omni/platforms/npu/worker/multi_step_decode.py:60 from vllm_ascend.ascend_forward_context import set_ascend_forward_context and :61-62 import vllm_ascend.ops.rotary_embedding as vllm_ascend_rotary / from vllm_ascend.ops.rotary_embedding import update_cos_sin. Unchanged sibling tests/worker/test_native_duplex_hooks.py:2480-2481 These checks parse the source instead of importing it: the NPU runner needs / vllm_ascend, which is absent on the CPU test images that run this file.` Adverse effect: collection raises ImportError and fails the entire job; importorskip('vllm_ascend') would skip the CPU-fake validator on that same lane.
Suggestion: import sys
import types
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
_ascend = types.ModuleType("vllm_ascend")
_fwd = types.ModuleType("vllm_ascend.ascend_forward_context")
_fwd.set_ascend_forward_context = lambda *a, **k: None
_ops = types.ModuleType("vllm_ascend.ops")
_rot = types.ModuleType("vllm_ascend.ops.rotary_embedding")
_rot.update_cos_sin = lambda *a, **k: None
_rot._cos = None
_rot._sin = None
_rot._cos_sin_cache = None
sys.modules.setdefault("vllm_ascend", _ascend)
sys.modules.setdefault("vllm_ascend.ascend_forward_context", _fwd)
sys.modules.setdefault("vllm_ascend.ops", _ops)
sys.modules.setdefault("vllm_ascend.ops.rotary_embedding", _rot)
import vllm_omni.platforms.npu.worker.multi_step_decode as msd_module
from vllm_omni.platforms.npu.worker.multi_step_decode import validate_multi_step_plan
There was a problem hiding this comment.
Fixed in bb7589b. The module under test is now loaded from source with stubbed vllm_ascend modules (sys.modules.setdefault, so a real install wins on NPU hosts) — the pattern tests/platforms/npu/test_310p_patches.py already uses — rather than importorskip, which would skip these pure-CPU validator tests on the same lane. Verified: pytest -m 'core_model and cpu' now selects all 53 tests in the two multi-step files; it selected none of them before.
| from vllm.v1.request import RequestStatus | ||
|
|
||
| import vllm_omni.core.sched.multi_step_decode as msd | ||
| from vllm_omni.core.sched.multi_step_decode import ( |
There was a problem hiding this comment.
[P1] tests/core/sched/test_multi_step_decode.py adds CPU planner/reconcile coverage…
Evidence and suggested fix
tests/core/sched/test_multi_step_decode.py adds CPU planner/reconcile coverage (no vllm_ascend import) with no pytestmark. Unchanged CUDA PR/merge Simple Other Test (.buildkite/cuda/test-ready.yml:18, same command in test-merge.yml) runs pytest -sv -m 'core_model and cpu', and pyproject.toml documents core_model as L1&L2 tests run in each PR. Unmarked tests are collected then deselected, so the prefill-gate/KV-accounting cases never run on that lane. Sibling tests/core/sched/test_omni_ar_scheduler_streaming.py:25 sets pytestmark = [pytest.mark.core_model, pytest.mark.cpu]. Add the same pytestmark after the imports.
Evidence: Trigger: this PR adds tests/core/sched/test_multi_step_decode.py with pytest imported but no pytestmark (after the import at line 21 the next code is WINDOWED_ARCH / a fixture). Adverse effect / unmet requirement: CUDA PR CI Simple Other Test only executes tests matching core_model and cpu, so these planner/reconcile cases are collected then deselected and never run. tests/core/sched/test_multi_step_decode.py:15 import pytest; tests/core/sched/test_multi_step_decode.py:21 from vllm_omni.core.sched.multi_step_decode import (; tests/core/sched/test_multi_step_decode.py:31 # The one architecture that declares ``supports_multi_step_decode`` today. — no pytestmark between import and tests. Unchanged by this diff, present in the PR-time tree: tests/core/sched/test_omni_ar_scheduler_streaming.py:25 pytestmark = [pytest.mark.core_model, pytest.mark.cpu]; unchanged by this diff, present in the PR-time tree: .buildkite/cuda/test-ready.yml:18 timeout 20m pytest -sv -m 'core_model and cpu' --ignore=tests/diffusion --ignore=tests/model_executor --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml; unchanged by this diff, present in the PR-time tree: pyproject.toml:225 "core_model: L1&L2 tests (run in each PR)",
Suggestion: from vllm_omni.core.sched.multi_step_decode import (
ENV_MULTI_STEP_STEPS,
MAX_MULTI_STEP_STEPS,
model_supports_multi_step,
plan_multi_step_window,
reconcile_window_shortfall,
resolve_multi_step_steps,
scheduler_allows_multi_step,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
There was a problem hiding this comment.
Fixed in bb7589b. Both modules now carry pytestmark = [pytest.mark.core_model, pytest.mark.cpu], so the CPU lanes select them instead of collecting and deselecting.
| try: | ||
| plan = getattr(scheduler_output, "multi_step_plan", None) | ||
| if not plan: | ||
| return _refuse("no_plan") |
There was a problem hiding this comment.
[P1] validate_multi_step_plan is invoked unconditionally from NPUARModelRunner.execu…
Evidence and suggested fix
validate_multi_step_plan is invoked unconditionally from NPUARModelRunner.execute_model (npu_ar_model_runner.py:494). An empty/missing multi_step_plan is the default (OmniSchedulerOutput.multi_step_plan defaults to {}; multi_step_decode_steps defaults to 0), but validate treats it as return _refuse("no_plan") (multi_step_decode.py:106). _refuse increments _refusal_counts and logs Multi-step plan refused at runner: %s (first occurrence) on first hit (multi_step_decode.py:88-91). NPUARModelRunner is the shared NPU AR runner (npu_ar_worker.py:19, docstring: thinker/talker), so every default-disabled NPU AR stage emits a misleading refusal warning and poisons the planned-vs-executed diagnostic on the baseline path. Return None without calling _refuse when there is no plan.
Evidence: Trigger: every NPUARModelRunner.execute_model when the stage never planned a window (default K=0 or ineligible arch/stage). npu_ar_model_runner.py:494 multi_step_plan = validate_multi_step_plan(self, scheduler_output) is unconditional. multi_step_decode.py:104-106 plan = getattr(scheduler_output, "multi_step_plan", None) / if not plan: / return _refuse("no_plan"). _refuse at multi_step_decode.py:88-91 _refusal_counts[reason] = _refusal_counts.get(reason, 0) + 1 then logger.warning("Multi-step plan refused at runner: %s (first occurrence)", reason). Default empty plan in this diff: vllm_omni/core/sched/output.py:104 multi_step_plan: dict[str, int] = field(default_factory=dict); vllm_omni/config/model.py:126 multi_step_decode_steps: int = 0. Unchanged by this diff, present in the PR-time tree: npu_ar_worker.py:12 """NPU AR worker for thinker/talker stages in Omni model.""" and npu_ar_worker.py:19 self.model_runner = NPUARModelRunner(self.vllm_config, self.device). Adverse effect: the first execute_model on any default-disabled NPU AR server logs a false "plan refused" warning; every later step increments _refusal_counts["no_plan"], so the planned>>executed diagnostic cannot distinguish a real systematic refusal from the baseline path.
Suggestion: if not plan:
return None
There was a problem hiding this comment.
Fixed in 981388f. An empty plan returns None without calling _refuse, so a default-configured NPU AR stage no longer logs "plan refused" on its first step and no longer accumulates no_plan per step. A fresh windowed run now reports refusals={} in the counters line.
| # single-step loop. Models must declare the contract via | ||
| # ``supports_multi_step_decode``; ineligible batches fall back to single | ||
| # steps automatically. | ||
| multi_step_decode_steps: int = 0 |
There was a problem hiding this comment.
[P1] This PR adds multi_step_decode_steps: int = 0 on OmniModelConfig (model.py:126)…
Evidence and suggested fix
This PR adds multi_step_decode_steps: int = 0 on OmniModelConfig (model.py:126) and OmniEngineArgs (arg_utils.py:172), and the planner tells operators to leave that key at 0 in the deploy config (multi_step_decode.py:50). load_deploy_config only copies active_stream_window (stage_config.py:663); OmniStageModelConfig / _ModelEngineOverrides still only declare active_stream_window (omni_config.py:259/57). A top-level YAML multi_step_decode_steps: 12 is dropped before merge, so resolve_multi_step_steps keeps K=0. Wire it as a per-stage engine field next to active_stream_window on OmniStageModelConfig and _ModelEngineOverrides (not _PIPELINE_WIDE_ENGINE_FIELDS — the thinker must not inherit a talker K), document it on the MiniCPM-o talker stage, and add a factory test that stage-1 multi_step_decode_steps: 12 survives merge_pipeline_deploy → to_omegaconf → OmniEngineArgs. Env VLLM_OMNI_MULTI_STEP_STEPS is currently the only documented opt-in.
Evidence: Trigger: operator follows the planner's deploy-config contract and sets top-level YAML multi_step_decode_steps: 12 (same pattern as active_stream_window). Adverse effect: the key never reaches OmniEngineArgs/OmniModelConfig, so resolve_multi_step_steps stays at default 0 and windows never open unless VLLM_OMNI_MULTI_STEP_STEPS is set. vllm_omni/core/sched/multi_step_decode.py:50 Rollback: leave ``multi_step_decode_steps`` at 0 (the default) in the deploy config.; vllm_omni/engine/arg_utils.py:172 multi_step_decode_steps: int = 0; vllm_omni/config/model.py:126 multi_step_decode_steps: int = 0. Unchanged by this diff, present in the PR-time tree: vllm_omni/config/stage_config.py:663 "active_stream_window": int(raw_dict.get("active_stream_window", 0) or 0), — load_deploy_config kwargs have no multi_step_decode_steps; vllm_omni/config/omni_config.py:259 active_stream_window: int = Field(default=0, ge=0) — OmniStageModelConfig still omits the new field; vllm_omni/config/omni_config.py:57-58 _ModelEngineOverrides only lists active_stream_window: int; vllm_omni/config/omni_config.py:729 _MODEL_ENGINE_FIELDS = frozenset(_ModelEngineOverrides.__annotations__) — structured factory copies only that whitelist.
There was a problem hiding this comment.
Fixed in 645a70c. Declared the field on both _ModelEngineOverrides (the typed engine projection) and OmniStageModelConfig (built from it), so a per-stage engine_extras.multi_step_decode_steps: 12 survives load_deploy_config -> merge_pipeline_deploy -> _stage_engine_values -> _build_model_config. It is intentionally not a pipeline-wide field: the thinker must not inherit the talker's K. Added a factory test pinning stage-1 only, and verified the test fails with KeyError when the projection declaration is removed.
| return None | ||
|
|
||
|
|
||
| def shrink_refused_multi_step_window(scheduler_output: SchedulerOutput) -> None: |
There was a problem hiding this comment.
[P2] shrink_refused_multi_step_window is a new public fail-closed helper (multi_step…
Evidence and suggested fix
shrink_refused_multi_step_window is a new public fail-closed helper (multi_step_decode.py:223) and NPUARModelRunner.execute_model always calls it after validate_multi_step_plan returns None (npu_ar_model_runner.py:512). tests/worker/test_multi_step_decode_runner.py only imports validate_multi_step_plan; repo-wide there are no tests/ hits. Pin a CPU unit test: plan {r0:8, r1:8} with num_scheduled_tokens 8 / total 16 becomes scheduled 1 and total_num_scheduled_tokens drops by the reclaimed count; a missing/empty plan is a no-op.
Evidence: Trigger: validate_multi_step_plan returns None while scheduler_output still carries a K-token plan; execute_model then always shrinks before the single-step fallback. Unmet requirement: this new public helper has no test. vllm_omni/platforms/npu/worker/multi_step_decode.py:223 def shrink_refused_multi_step_window(scheduler_output: SchedulerOutput) -> None: — public shrink. multi_step_decode.py:240-250 plan = getattr(scheduler_output, "multi_step_plan", None) / if not plan: return / scheduler_output.num_scheduled_tokens[req_id] = 1 / scheduler_output.total_num_scheduled_tokens -= reclaimed — empty plan is a no-op; otherwise K→1 and total is reduced by the reclaimed count. vllm_omni/platforms/npu/worker/npu_ar_model_runner.py:494 multi_step_plan = validate_multi_step_plan(self, scheduler_output) then :512 shrink_refused_multi_step_window(scheduler_output) — called on every None return so a refused K-token schedule cannot run as a plain multi-token decode. tests/worker/test_multi_step_decode_runner.py:18 from vllm_omni.platforms.npu.worker.multi_step_decode import validate_multi_step_plan — runner CPU tests import only the validator; repo-wide grep of shrink_refused_multi_step_window has no tests/ hits. Adverse effect if shrink is wrong/untested: fallback execute_model would still see num_scheduled_tokens=K and write KV on the unproduced K-1 slots (stale embeddings + cache holes).
There was a problem hiding this comment.
Fixed in 981388f. Covered shrink_refused_multi_step_window: full shrink ({r0:8, r1:8} -> 1/1, total 16 -> 2), unplanned requests left untouched, and the empty/missing-plan no-op.
| # exits early. num_in_flight_tokens is kept symmetric with | ||
| # num_computed_tokens in both modes (the base scheduler adds 1 for the | ||
| # pre-rewrite decode token, the runner's update_from_output subtracts K). | ||
| async_mode = bool(getattr(scheduler.scheduler_config, "async_scheduling", True)) |
There was a problem hiding this comment.
[P2] plan_multi_step_window:363-367 skips num_output_placeholders inflation when asy…
Evidence and suggested fix
plan_multi_step_window:363-367 skips num_output_placeholders inflation when async_scheduling is false; reconcile_window_shortfall then rolls back only num_computed_tokens. That is the live MiniCPM-o path (deploy YAMLs set async_scheduling: false; OmniARScheduler inherits the plan hook). tests/core/sched/test_multi_step_decode.py pins async_scheduling=True in both fixtures and every plan/reconcile assertion is the async placeholder-fence path; the only sync coverage is runner-side test_validate_accepts_sync_scheduling. Module docstring L2 and scheduler_allows_multi_step docstring L151 still say async-only even though the gate does not check async_scheduling. Add a CPU planner test with async_scheduling=False that asserts placeholders stay uninflated and shortfall reconcile rolls back computed tokens only, and update those two docstrings.
Evidence: Trigger: enabling multi_step_decode_steps on MiniCPM-o with existing sync deploys (unchanged by this diff: vllm_omni/deploy/minicpmo_4_5_duplex.yaml async_scheduling: false) selects OmniARScheduler, which calls plan_multi_step_window. Adverse/unmet: that commit skips the placeholder fence and has no planner unit test, while public docs still claim async-only. vllm_omni/core/sched/multi_step_decode.py:2 """Multi-step decode window planning for AR stages on async scheduling.; L151 AR stages on async scheduling are eligible.; L363-367 async_mode = bool(getattr(scheduler.scheduler_config, "async_scheduling", True)) then if async_mode: request.num_output_placeholders += extra (sync leaves placeholders untouched, then always request.num_computed_tokens += extra). tests/core/sched/test_multi_step_decode.py:126 and :164 both scheduler_config=SimpleNamespace(async_scheduling=True), — no async_scheduling=False planner case. Runner-only sync check is tests/worker/test_multi_step_decode_runner.py:138 def test_validate_accepts_sync_scheduling():.
There was a problem hiding this comment.
Fixed in 981388f. Added a planner test with async_scheduling=False asserting placeholders stay uninflated and that the shortfall reconcile rolls num_computed_tokens back directly. The "async only" wording is gone from scheduler_allows_multi_step, the module header, and the runner's fail-closed notes; the header's fence description now matches the implementation (explicit per-request hold-back, since the placeholder counters alone do not fence).
Omni ReviewBot: finding feedback[p1] tests/core/sched/test_multi_step_decode.py adds CPU planner/reconcile coverage… — See the review for details. If you are the PR author and disagree, react 👎 here; the maintainer will see your disagreement. |
Omni ReviewBot: finding feedback[p1] Accepted windows return from execute_model at npu_ar_model_runner.py:506 before… — See the review for details. If you are the PR author and disagree, react 👎 here; the maintainer will see your disagreement. |
Omni ReviewBot: finding feedback[p0] New tests/worker/test_multi_step_decode_runner.py:17 collection-imports vllm_om… — See the review for details. If you are the PR author and disagree, react 👎 here; the maintainer will see your disagreement. |
Omni ReviewBot: finding feedback[p1] This PR adds multi_step_decode_steps: int = 0 on OmniModelConfig (model.py:126)… — See the review for details. If you are the PR author and disagree, react 👎 here; the maintainer will see your disagreement. |
Omni ReviewBot: finding feedback[p1] validate_multi_step_plan is invoked unconditionally from NPUARModelRunner.execu… — See the review for details. If you are the PR author and disagree, react 👎 here; the maintainer will see your disagreement. |
amy-why-3459
left a comment
There was a problem hiding this comment.
Reviewed commit d03aed315bdb3491aca88b1e2289bcd2f6ed934f.
Found two actionable correctness bugs in multi-step decode: incorrect rotary positions for concurrent requests and unsafe async fallback accounting.
[P1] Build rotary positions in the order consumed by the window
vllm_omni/platforms/npu/worker/multi_step_decode.py:333
The prebuild flattens positions request-first, but the execution loop consumes contiguous blocks of num_reqs rows as step-first data. With two requests at positions 100 and 200 and K=2, this builds [100, 101, 200, 201], while the loop requires [100, 200, 101, 201]. Consequently, whenever rotary prebuild succeeds with multiple requests, even step 0 applies the wrong rotation to the second request, corrupting attention and generated audio. Reverse the comprehension nesting to match the per-step slices.
[P1] Fence async windows before allowing shortfall rollback
vllm_omni/core/sched/multi_step_decode.py:366
Increasing placeholders and computed tokens equally does not fence subsequent scheduling: num_tokens_with_spec + placeholders - computed remains 1, as the added test_plan_window_preserves_confirmed_count_invariant explicitly asserts. Async scheduling can therefore queue another window using the first window's full reserved length. If the runner refuses the first window—for example because debugger is enabled, which the scheduler gate does not reject—shrink_refused_multi_step_window executes only one token, but the queued schedule still starts K positions later. Reconciliation changes the scheduler request counters without repairing that queued schedule, leaving unwritten KV positions in the attention context and corrupting subsequent output. Prevent scheduling on outstanding windows or repair/invalidate dependent queued schedules before permitting fallback or early-exit rollback.
Validation: Statically inspected the exact base-to-head diff, all changed files, scheduler and runner callers, model sampling/output handling, and both added test files. Cross-checked relevant locally available dependency source. The added runner tests exercise validation, not window execution. No repository code, runtime tests, or GPU/NPU tests were run.
linyueqian
left a comment
There was a problem hiding this comment.
The async window accounting and finished-request cleanup need attention before this can land. The inline comments describe the affected paths; I also left a suggestion regarding the rotary prebuild layout.
Static review at d03aed3 against minicpm-challenge, including the runner, scheduler, model lifecycle, and added tests. I did not run tests, NPU benchmarks, or e2e, and have not independently verified the reported performance or audio equivalence.
| async_mode = bool(getattr(scheduler.scheduler_config, "async_scheduling", True)) | ||
| for req_id, request in windowed: | ||
| if async_mode: | ||
| request.num_output_placeholders += extra |
There was a problem hiding this comment.
[important] We should fence dependent async schedules until the window is reconciled, or ensure already-issued schedules are correctable. Adding K-1 to both placeholders and computed tokens leaves num_tokens + placeholders - computed == 1, allowing another decode step to be queued before this window returns (which the new invariant test explicitly expects). If the runner refuses the window, such as when prefix caching is enabled, it writes only one token while the queued schedule still starts K positions ahead. reconcile_window_shortfall repairs the live counters, not the already-queued positions. Even without a refusal, _request_window_budget does not account for in-flight windows: with 116 confirmed tokens, 100 output tokens, max_tokens=112, max_model_len=128, and K=8, two queued windows cover positions 115..122 and 123..130. The latter exceeds the context limit despite the base scheduler clamp. Please add test coverage for both a refused window followed by a queued step and stacked windows near the context limit.
There was a problem hiding this comment.
Fixed in 221cd42. Planned-but-unreconciled windows are now tracked per request (req_id -> K on the scheduler), a second window cannot stack on top of one, and OmniARScheduler.schedule() holds such requests out of the pass until update_from_output reconciles them. That closes the scenario: no follow-up schedule is ever emitted against a window's optimistic positions, so a runner refusal (one token written) is fully recoverable by reconcile_window_shortfall + shrink_refused_multi_step_window.
Your reading of the accounting is exactly right, and the test you cite did encode that premise: adding K-1 to both num_output_placeholders and num_computed_tokens leaves num_tokens + placeholders - computed == 1, so the counters alone never fenced — the fence is now explicit rather than implied.
The two concrete triggers you named also cannot fire any more: enable_prefix_caching is now part of the scheduler-side static gate (9a5b83e), so the planner no longer emits a window the runner refuses with a shrink; and the stacked-window case you described is unreachable with at most one window in flight, which also makes _request_window_budget (max_model_len - num_tokens) exact instead of optimistic.
Tests added: the planner refuses a window while one is outstanding and accepts it again after reconcile; the deferral filters exactly the fenced requests out of the running queue while plain decodes still run; the sync accounting path inflates only num_computed_tokens and rolls it back on shortfall; and a fully deferred pass needs no new guard because the runner's existing num_scheduled_tokens <= 0 path already returns an empty output before _prepare_inputs (#5196) while still running _update_states and on_requests_finished.
Scope note: a genuine exception thrown while the window executes still propagates rather than being swallowed, so the refusal set the fence must absorb is the static-validation one — which the scheduler now mirrors.
| if multi_step_plan is not None: | ||
| if self._msd_timing is not None: | ||
| self._msd_win_t0 = time.perf_counter() | ||
| execute_multi_step_window(self, scheduler_output, multi_step_plan) |
There was a problem hiding this comment.
[important] Please preserve model.on_requests_finished on the window path. A schedule can retire finished request A while opening a window for still-running request B. Because this branch returns early before lines 522-523, and execute_multi_step_window only calls _update_states (which cleans up runner state without notifying the model), the MiniCPM talker never queues request A for _flush_deferred_cleanup. Its per-request generators and audio states therefore remain retained after completion. Moving the callback into shared handling or invoking it right after the window state update would address this. Please also cover a finished request retiring alongside an active window.
There was a problem hiding this comment.
Fixed in 88f223c. Extracted notify_finished_requests() and call it right after _update_states inside the window executor — the same point the single-step path uses — so a request that retires while its siblings keep windowing is queued for _flush_deferred_cleanup instead of retaining its _request_audio_states / generator (and the omni wrapper drops its duplex session). Covered by tests for the forwarding case plus the empty-ids and missing-hook no-ops.
| if window_k * num_reqs > int(cos.shape[1]): | ||
| return False | ||
| all_pos = torch.tensor( | ||
| [int(comp_cpu[i]) + j for i in range(num_reqs) for j in range(window_k)], |
There was a problem hiding this comment.
[suggestion] Consider aligning this layout with the replay loop and adding a two-request test with unequal positions. The list comprehension is request-major, but lines 587-594 consume step-major slices: positions 100 and 200 with K=2 produce [100, 101, 200, 201], so the first slice is [100, 101] rather than [100, 200]. Reversing the loop nesting would match the consumer order. I have not established that the active MiniCPM path consumes this cache, so I am not treating this as evidence of audio corruption.
There was a problem hiding this comment.
Fixed in 2a61fef. You were both right, and thank you for describing it as an ordering question rather than a claim — I could not leave it as-is.
The prebuild flattened positions request-major while the replay loop consumes them step-major (each step copies j*num_reqs..(j+1)*num_reqs into the rows the captured graph reads), so two requests at 100/200 with K=2 built [100,101,200,201] where the loop needs [100,200,101,201] — request 1 would get request 0's rotation from step 0 on. Reversed the nesting and extracted the flatten into window_cos_sin_positions(); the added tests pin the batched order, the per-step slices, and the pre-fix order as a regression guard.
On whether the cache is actually consumed on this model: it is not, today. I added a diagnostic counter (cos_prebuilt_windows in the existing "Multi-step counters" line) and a windowed c4 run reports 0 — _prebuild_window_cos_sin early-returns because the Ascend cos/sin cache is absent, which is also why update_cos_sin is effectively inert here. So this was a latent ordering bug rather than a source of corrupted audio, and no multi-concurrency re-measurement is needed on its account.
The CUDA PR/merge lane runs `pytest -m 'core_model and cpu'` over the whole tests/ tree. Neither new test module carried a pytestmark, so both were collected and then deselected: the planner/reconcile and validator cases never executed there. Add the marker to both. tests/worker/test_multi_step_decode_runner.py also imported vllm_omni.platforms.npu.worker.multi_step_decode, whose package __init__ pulls the NPU platform stack (vllm_ascend.platform) and whose module body imports the Ascend forward context and the rotary cos/sin cache. None of that exists on the CPU images, where a collection ImportError fails the whole job before markers can deselect. Load the module from source -- the pattern already used by tests/platforms/npu/test_310p_patches.py -- and stub the vllm_ascend attributes it needs via sys.modules.setdefault, so a real install still wins on NPU hosts and these CPU-fake tests are not skipped on the CUDA lane. Verified: `pytest -m 'core_model and cpu'` now selects all 42 tests (it selected none of them before). Signed-off-by: Green-Vial <Green-Vial@users.noreply.github.com>
…dows The WIA rotary prebuild flattened positions request-major ([comp_i + j for i ... for j ...]) while the replay loop consumes them step-major: each step copies the block j*num_reqs..(j+1)*num_reqs into the rows the captured graph reads. With more than one request, request i > 0 got request 0's positions from step 0 on -- e.g. requests at 100 and 200 with K=2 built [100, 101, 200, 201] where the loop needs [100, 200, 101, 201] -- corrupting the rotary phase (and therefore attention) of every batched window. num_reqs == 1 collapses both orders, which is why single-request runs were unaffected. Extract the flatten into window_cos_sin_positions() so the ordering is directly testable, and cover the batched order plus the single-request equivalence. Signed-off-by: Green-Vial <Green-Vial@users.noreply.github.com>
…positions Async scheduling runs a batch queue (max_concurrent_batches == 2 whenever async_scheduling is on with PP == 1), so the next scheduler pass is built before the previous window's output lands. Adding K-1 to num_output_placeholders and num_computed_tokens together leaves `num_tokens + placeholders - computed == 1`, i.e. the request stays immediately schedulable: it can be handed a follow-up step -- or even a second window -- positioned K tokens ahead of what the window may actually confirm. reconcile_window_shortfall repairs the counters, not an already-emitted schedule, so a runner refusal (only one token written) would leave the slots in between permanently unwritten. - Track planned-but-unreconciled windows on the scheduler (req_id -> K) and refuse to plan a second one on top of them. That also stops two reservations from walking past the context limit the base scheduler just clamped, which _request_window_budget cannot see (it only sees confirmed tokens). - OmniARScheduler.schedule() holds fenced requests out of the pass until update_from_output reconciles them, so no follow-up schedule is ever emitted against an optimistic position. Sync scheduling is strictly serial (max_concurrent_batches == 1) and never reaches this path. - NPUARModelRunner needs no new empty-schedule guard: a fully deferred pass ends up with zero scheduled tokens, which the runner's existing `num_scheduled_tokens <= 0` guard already handles -- it returns an empty output before `_prepare_inputs`'s `assert total_num_scheduled_tokens > 0` (vllm-project#5196) is reached, and still runs _update_states and on_requests_finished first (which an earlier guard here would have skipped). - scheduler_allows_multi_step docstring: both scheduling modes qualify. Signed-off-by: Green-Vial <Green-Vial@users.noreply.github.com>
… path execute_model's window branch returns before the single-step path's on_requests_finished call. A schedule can retire request A while still opening a window for its still-running siblings, and the hook's ids are one-shot on that SchedulerOutput, so A was never queued for _flush_deferred_cleanup: its _request_audio_states and per-request generators stayed retained, and the omni wrapper's duplex sessions were never dropped. Extract notify_finished_requests() and call it right after _update_states -- exactly where the single-step path does it -- so both paths share one lifecycle hook. Signed-off-by: Green-Vial <Green-Vial@users.noreply.github.com>
…onfig multi_step_decode_steps existed on OmniEngineArgs and OmniModelConfig but was not declared on the typed engine-arg projection, so a per-stage `engine_extras.multi_step_decode_steps: 12` in the deploy YAML was dropped by `_select_engine_overrides(engine, _MODEL_ENGINE_FIELDS)` and the window stayed disabled unless the env var was set -- the documented YAML opt-in silently did nothing. Declare it on _ModelEngineOverrides (the typed projection) and on OmniStageModelConfig (built from that projection). It is deliberately a per-stage field, not pipeline-wide: the thinker must not inherit the talker's K. Test: a stage-1 engine_extras value survives load_deploy_config -> merge_pipeline_deploy -> _stage_engine_values -> _build_model_config, and the other stages keep the default. Verified the test fails with KeyError when the projection declaration is removed. Signed-off-by: Green-Vial <Green-Vial@users.noreply.github.com>
…sync paths
- validate_multi_step_plan returned _refuse("no_plan") for an empty plan, which
is the default (multi_step_decode_steps == 0, or a step the planner left
alone). Every default-configured NPU AR stage therefore logged a warning on
its first execute_model and counted a refusal on every step, drowning the
planned-vs-executed diagnostic in baseline noise. Return None without
counting or warning.
- Cover shrink_refused_multi_step_window (previously untested): full shrink,
non-windowed requests left alone, and the empty/missing-plan no-op.
- Cover the sync planner path: async_scheduling=False inflates only
num_computed_tokens (no placeholder fence) and the shortfall reconcile rolls
computed tokens back directly.
- Docstrings: the module header, scheduler_allows_multi_step and the runner's
fail-closed notes said "async only" and described the placeholder counters as
the fence. Both scheduling modes are supported, and the fence is the explicit
per-request hold-back; a refusal is recoverable because no follow-up schedule
exists while a window is unreconciled.
Signed-off-by: Green-Vial <Green-Vial@users.noreply.github.com>
The WIA rotary prebuild is a pure optimization with a silent early-return fallback, so an inert prebuild (cos/sin cache absent, e.g. when the platform handles rotary inside attention) is indistinguishable from an active one in the logs -- a c4 run showed cos_prebuilt_windows=0, i.e. the batched-ordering path is never exercised on this model. Count the windows that actually took the prebuild and print it in the existing "Multi-step counters" diagnostic line next to executed_windows/refusals. Also pin the pre-fix request-major ordering in the ordering test so a regression is caught at the unit level. Signed-off-by: Green-Vial <Green-Vial@users.noreply.github.com>
…dow is refused The NPU window executor refuses to window while the omni tensor prefix cache is active, but the scheduler-side static gate did not check it, so a server with prefix caching enabled would plan a window on every eligible step and have the runner shrink it back -- paying the plan/allocate/reconcile churn for nothing. The fence makes such a refusal recoverable, but not emitting the doomed plan is better. `enable_prefix_caching` is exactly when the runner builds the omni prefix cache object, so the two gates now agree. Added to the static-gate refusal test. Signed-off-by: Green-Vial <Green-Vial@users.noreply.github.com>
Fixes #7077.
Purpose
This PR adds Multi-Step Merged Decode to vLLM-Omni: the scheduler merges K consecutive decode steps into a single scheduling round, and the runner replays all K steps inside one
execute_modelcall. Each engine-loop iteration now produces K tokens per request instead of 1, amortizing the fixed per-step host cost of the engine loop (schedule, IPC, output processing, block-table maintenance) by K.The mechanism is opt-in (
VLLM_OMNI_MULTI_STEP_STEPS, default0= disabled, behavior identical to baseline), works under both async and sync scheduling, and is fully implemented with 41 unit tests.Measured results (MiniCPM-o 4.5 talker, Atlas 910B3, stage-1 FULL cudagraph baselines, 2 scheduling modes x 3 concurrency levels x {K=0, 6, 8, 12, 14} = 30 groups): every configuration improves, and the best window size per leg gives:
All percentages are relative to the same leg's K=0 (window disabled) baseline under stage-1 FULL cudagraph. Audio output is identical to baseline: the c1 codec streams are bit-identical (per-token dump compare), and total audio duration is unchanged within <1% at c4/c8 (batching-order effects). Recommended window size: K=12 for sync legs, K=14 for c4 async, K=8 for c8 async; K=12 is the safest single choice across all legs.
Background
vLLM-Omni splits a multimodal request into stages executed by independent engine processes. AR stages (e.g. the MiniCPM-o 4.5 TTS talker, 20 layers, hidden 768) are small models doing pure decode. Every generated token pays one full engine round trip:
The per-step wall time has two components: a fixed host share (scheduling logic, message serialization, shared-memory transfer, output processing — roughly 2 ms per step under stage-1 FULL cudagraph) and the device forward (roughly 7-8 ms). Because the host share is paid once per token and the batch composition of a decode batch is highly stable (running batches do not take new requests mid-step), the host share can be amortized by replaying K consecutive steps inside one engine iteration and feeding each step's sampled token directly into the next.
Multi-step windows are real sampling (lossless — no draft/reject, no distribution change), mutually exclusive with spec decode, and gated on the talker stage's capability contract.
Platform applicability: the mechanism (planning / accounting / gating / capability contract) is platform-agnostic framework code; the K-step executor currently ships with the NPU runner only. Platforms without the executor are refused by a fail-closed static gate — enabling the flag elsewhere is a no-op. See Porting in the appendix.
Test Plan
Unit tests (41, pure CPU, no NPU required):
Coverage: window-size resolution (config/env/clamping/invalid values), static gates (parallelism / spec decode / LoRA / mamba / platform refusal paths), capability contract (stage-qualified tts=True / llm=False, stage flag taking precedence over a blanket flag), plan rewriting (new-request / non-uniform batch / prefill-phase / penalty refusals, budget clamping, KV allocation failure with block-row sync, confirmed-length invariant), reconcile (full report no-op / shortfall rollback / clamping / empty report), runner validation (refusal paths + fail-closed exception fallback), and both scheduling modes (async placeholder accounting and sync direct accounting are both accepted end to end).
Gate-open verification (INFO level, no DEBUG needed):
executed_windows > 0with only expected fail-closed refusals (no_plan) means windows really execute; per-group windows/steps are also recorded in the appendix.Benchmark methodology (full records in Appendix F): Atlas 910B3 single NPU; original
OpenBMB/MiniCPM-o-4_5(bf16, not quantized); three stages co-located on one card; Seed-TTS EN branch, first 32/64/128 sessions for c1/c4/c8, request-rateinf, 2 warmups, no oversample; stage-1cudagraph_mode: FULLfor every group (including the K=0 baseline); serve restarted per group; Mean E2EL / TTFT / AUDIO_RTF + stage1 per-token time recorded. The same matrix is run under async scheduling and under sync scheduling (async_scheduling: falseon stage 1), giving 2 x 3 x 5 = 30 groups.Quick A/B: run the same serve config twice with
vllm bench serve --omni --save-result(first without the env var, then withVLLM_OMNI_MULTI_STEP_STEPS=<K>) and compare the saved JSON.Test Result
Per-leg best window sizes (vs each leg's K=0 baseline, stage-1 FULL graph) are shown in Purpose. Summary of the full 30-group sweep (Appendix F):
Interpretation
RTF and E2EL move together here (unlike host/device-mixed stages): every leg's best K improves both, because the talker is the pipeline's step-rate bottleneck and the window reduces its step time directly.
Sync vs async: the sync scheduler keeps at most one window in flight (schedule -> execute -> update strictly serial), so window accounting is simpler (no placeholder fence, direct optimistic
num_computed_tokensrollback) and windows stay open longer under load — hence larger, more consistent gains. Async scheduling overlaps planner and executor and closes windows more readily; it still gains, but less.E2EL vs stage1 per-token: stage1 is one of three co-located stages, so the end-to-end gain (~7-20%) is smaller than the stage1 gain (~20-26%); the remainder of the pipeline (thinker, codec decoder) is untouched.
Where the gain comes from, and its limits
Of the ~9.4 ms single-step time under stage-1 FULL cudagraph, the engine-loop host share (schedule, IPC round trips,
update_from_output, block-table maintenance — roughly 2 ms) is amortized K-fold — this is the direct source of the gain. The device forward plus runner-internal host work (input prep, attention metadata, kernel launch, sampling — roughly 7.4 ms) executes every in-window step as usual and is not amortized, which caps the gain far below K-fold (measured ~20-26% at the stage level, consistent with a ~21% host share). Four boundaries:flag.item()) and synchronous sampler readback interrupt the device pipeline each step, compressing the amortization gain.Engineering conclusion: recommend K=12 (opt-in), default 0 — with no configuration the behavior is identical to baseline. For c4-concurrency async deployments K=14 measured best; K=12 remains within noise of it.
Appendix
A. Design
A.1 Window planning and accounting
Async scheduling tracks in-flight tokens with
num_output_placeholders. The window makes one engine step look like "one scheduling round, K sampled tokens per request":allocate_slots), appends the new blocks tonew_block_ids, and inflatesplaceholders += K-1,computed += K-1,num_in_flight_tokens += K-1.computed - placeholders(confirmed KV length) is unchanged by the patch — no vLLM core accounting is touched.reconcile_window_shortfallrolls the extra reservation back socomputedmatches the actually-written KV slots.Under sync scheduling no placeholder fence exists (one window in flight max, the engine loop is strictly serial), so the same patch only inflates
computedandnum_in_flight_tokens; the shortfall is rolled back directly.A.2 Multi-concurrency safety
A frozen batch delays admission of new requests for K steps. The planner therefore refuses to open windows whenever the waiting queue is non-empty (new work is admitted first) or when the batch contains newly scheduled requests. Under bursty arrivals windows simply close and reopen; TTFT is not blocked by windowed steps.
A.3 Fail-closed three-layer gating
Any layer can refuse; refusal is always a lossless fallback to the normal single-step path, with the scheduler-side reconcile absorbing the reservation:
A.4 Model capability contract
A stage only opens windows if its model class declares the contract:
supports_multi_step_decode = Trueon the AR model class; orsupports_multi_step_stages = ("tts",)— gating resolves permodel_config.model_stage, so the thinker stage of the same wrapper never opens windows.The contract requires:
preprocess(input_ids, input_embeds, **info): builds the step's decode embedding purely from the request-local state chain (previous sampled codec token + per-request cache);make_omni_output: advances that state chain in place;Models that don't satisfy the contract simply don't declare it; the gates refuse and behavior is identical to baseline.
A.5 Runner K-step loop
One uniform decode layout is rebuilt up front and reused for all K steps (batch composition is frozen):
query_start_loc/query_pos/num_scheduled_tokens,_build_attn_state, batch descriptor and cudagraph mode. Then per step:Amortized per window:
schedule(), IPC round trips,_update_states, block table commit, cudagraph decisions, output build/serialization. Two further in-window amortizations (WIA) trim the loop itself:K * num_reqscos/sin rows are filled with oneupdate_cos_sin; each step copies the prebuilt block into the rows the captured graph reads (a plain device copy) instead of per-step index-select/repeat/chunk.int(state.finished)once themin_tokensmask is released, so the device logits-prep + sampler + token-gather path is skipped. Safety gates refuse on non-greedy / logit-bias / native-duplex sampling, and the derived tokens are emitted in the engine's token dtype (int32) so the async feedback scatter into the int32input_idsbuffer stays type-correct.FULL-graph padding (NPU-specific): under FULL cudagraph replay, FIA attention's TND layout requires
sum(query_start_loc) == hidden_states rows. When cudagraph padding makesnum_tokens_padded > num_reqs, the window loop mirrors the single-step_pad_query_start_loc_for_fiabehavior by inserting a dummy request row (its KV rows point at block 0;reshape_and_cacheslices by unpadded token count so nothing is written) and passes the paddednum_reqs_paddedto_build_attention_metadata.B. Changes
Framework-generic (scheduler side, platform-agnostic):
vllm_omni/core/sched/multi_step_decode.pyplan_multi_step_window), reconcile (reconcile_window_shortfall), static gate, capability resolution, budget clamp,resolve_multi_step_steps; async and sync accounting pathsvllm_omni/core/sched/output.pymulti_step_plan: dict[str, int](req_id -> K)vllm_omni/core/sched/omni_ar_scheduler.pyschedule()tail tries window planning (try/except, falls back);update_from_output()reconciles per planvllm_omni/engine/arg_utils.pymulti_step_decode_steps: int = 0, forwarded to model configvllm_omni/config/model.pymulti_step_decode_steps: int = 0Platform rollout (NPU runner; other platforms pending port):
vllm_omni/platforms/npu/worker/multi_step_decode.pyvllm_omni/platforms/npu/worker/npu_ar_model_runner.pyexecute_model()head hook (validate -> execute -> return);sample_tokens()hands over the stashed output; env-gated per-step timingModel instance (MiniCPM-o 4.5; other models declare per the contract):
vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni.pysupports_multi_step_stages = ("tts",)vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni_tts.pysupports_multi_step_decode = TrueTests:
tests/core/sched/test_multi_step_decode.py(30),tests/worker/test_multi_step_decode_runner.py(11).B.1 Commit breakdown
The PR is three commits, each self-contained and independently correct (reviewable in order):
Multi-step merged decode: K decode steps per engine step on async scheduling— the core mechanism: scheduler-side planning/accounting/reconcile, fail-closed three-layer gating, capability contract, the NPU runner's K-step replay loop, and the 41 unit tests. Everything works end to end on async scheduling.Prebuild attention metadata for multi-step decode windows— inside the window loop, steps 1..K-1 reuse step-0's attention metadata (pure decode windows only vary in per-step sequence lengths); the per-step builder remains as fallback. First WIA item, still async-only.Multi-step windows on sync scheduling + window-internal amortization— extends windows to the sync scheduler (direct accounting, step-0 input from the request-local output chain) and adds the remaining WIA items (rotary prebuild, engine-token derivation) plus the fixes that make both paths exact: themin_tokensmask is counted from the runner's request-local output length (the sampler metadata view is None on the window path, which stuck the mask and re-scheduled finished requests for ~1900 dead windows under sync), and derived tokens are emitted int32 to match the engine token layout (an int64 src crashes the input scatter on the batch-reorder path). Includes the env-gated stage1 timing used to produce the per-token numbers below.C. Usage
D. Tuning and porting
D.1 Window size K (swept: K=6/8/12/14)
D.2 Deciding whether this class of optimization fits (methodology)
grep "Multi-step counters" serve.logforexecuted_windows > 0; the planned-vs-executed gap is the refused-churn cost.D.3 Porting to other models
Satisfy the capability contract, two steps:
supports_multi_step_decode = True(wrapper architectures declaresupports_multi_step_stageson the outer class).preprocess()depends only on request-local state (per-step info comes from the runner's model-intermediate buffer, includingduplex_token_offset/ computed-token counts).Models that don't satisfy the contract simply don't declare; gates refuse and behavior matches baseline.
D.4 Porting to other platforms (GPU/...)
The K-step executor currently ships with the NPU runner only. Three steps:
execute_model()/sample_tokens()hooks on the target platform's AR runner (reuse the validation and K-step loop from the NPU worker module, replacing platform-specific forward-context / rotary calls).D.5 Known boundaries (gated automatically, no manual action needed)
E. Rollback
engine_extras.multi_step_decode_steps, set it to 0, or unset the environment variable — behavior identical to baseline.async_schedulingdefault,cudagraph_mode: PIECEWISE).F. Test records
F.1 Protocol — Atlas 910B3 single NPU; original
OpenBMB/MiniCPM-o-4_5(bf16); three stages co-located; Seed-TTS EN, first 32/64/128 sessions (c1/c4/c8), request-rateinf, 2 warmups, no oversample; stage-1cudagraph_mode: FULLfor all groups; serve restarted per group; async and sync scheduling each measured over the full K sweep. Window execution confirmed per group via runner counters (executed_windows/executed_steps, onlyno_planrefusals). All groups completed with zero failed requests.F.2 Equivalence verification — codec streams bit-identical between windowed and baseline runs (per-token dump compare, c1); total audio duration identical at c1 (134.92 s across every configuration); c4/c8 total durations within <1% across K (batching-order effects). The min_tokens and token-dtype fixes in commit 3 are what make the sync and async window paths exact.
F.3 Full sweep —
mode | conc | K | E2EL(ms) | TTFT(ms) | RTF | audio(s) | stage1 ms/token. Stage1 ms/token: single-step path at K=0, window path at K>0.Sync scheduling:
Async scheduling:
Cell format:
E2EL(ms) / TTFT(ms) / RTF / stage1 ms-per-token. Bold = best E2EL per leg; audio (s): c1 = 134.92 for every cell; c4 = 279.0-281.4; c8 = 531.6-538.8.F.4 Window counters (representative; windows/steps at the best K per leg): sync c1 K12 251/3012, async c1 K6 601/3606, sync c4 K14 401/5614, async c4 K14 401/5614, sync c8 K12 851/10212, async c8 K8 1301/10408. Executed steps / K = executed windows (full windows; early exits reconciled by the scheduler shortfall path).
F.5 Reproducibility notes — run-to-run E2EL variance on this host is roughly +/-3% (c1). All groups above are single runs per cell; the best-K conclusions were double-checked with a repeated c1 sync K=12 A/B (-6.2% to -7.7% E2EL across repetitions, same direction).