# Runner-to-model prefill/decode phase contract ## Overview Autoregressive models with a custom `preprocess` hook need to distinguish prompt processing from decoding. A one-token span can be either a complete one-token prompt, the last chunk of a longer prompt, or a decode step. Using span length as the phase signal can replace a prompt embedding with a generated audio embedding and advance teacher-forced text too early without an exception. [Issue #4382](https://github.com/vllm-project/vllm-omni/issues/4382) requests a stable contract for the metadata and routing guards introduced by [PR #3662](https://github.com/vllm-project/vllm-omni/pull/3662). The shared runner already implements those guards. This design documents the existing interface and adds runner-level regression coverage rather than introducing a second phase detector or changing scheduling policy. ## Scope and objectives - Make the three existing phase keys safe to depend on in out-of-tree models. - Preserve prompt embeddings and model state on a one-token prefill tail. - Keep normal and batched decode routing consistent in mixed batches. - Explain the correctness workaround required on older releases. This contract does not enable chunked prefill for models whose other state handling does not support it. It does not change the scheduler, introduce new kwargs, or standardize other `_omni_*` fields. The implementation adds documentation and regression coverage; no hot-path allocation is added. ## Design ### Ownership and data flow ```text Scheduler / current input batch prompt_token_ids + num_computed_tokens_cpu (before this step) | OmniGPUModelRunner._preprocess | per-request phase metadata / \ prefill / other spans single-token decode preprocess preprocess or preprocess_decode_batch | | prompt embedding decode embeddings | talker_mtp (if present) \ / model forward ``` The producer is `OmniGPUModelRunner._preprocess` in `vllm_omni/worker/gpu_model_runner.py`. `GPUARModelRunner` and `GPUGenerationModelRunner` inherit it. The current `OmniNPUModelRunner` also inherits this method; device-specific execution still needs its own hardware validation. A plugin runner that overrides preprocessing is responsible for preserving this interface. ### Stable model-facing fields The following names, types, and meanings are a supported interface for in-tree and out-of-tree model hooks despite the leading underscore. Models should treat them as read-only runner-owned values. Changes to their meanings or removal require an explicit compatibility/migration plan. | Key | Python type | Meaning | | --- | --- | --- | | `_omni_prompt_len` | `int` | Length of the current request state's `prompt_token_ids`. This is the stage's prompt length, not the current chunk size or raw text length. | | `_omni_num_computed_tokens` | `int` | The row's `input_batch.num_computed_tokens_cpu` value **before** processing the scheduled span. It includes already-computed/cached progress, not just tokens computed in the current call. | | `_omni_is_prefill` | `bool` | Exactly `_omni_num_computed_tokens < _omni_prompt_len`. Equality and greater progress mean decode. | The runner refreshes the fields after current request state has been gathered, immediately before selecting the per-row hook. Existing values in intermediate model state cannot override the current phase. Row indices follow the current input batch (including any reordering), not request arrival order. A resumed or streaming request is classified from its **current** prompt/progress values; the phase is not latched for the lifetime of the request. The normal hook receives these fields as keyword arguments: ```python def preprocess(self, input_ids, input_embeds, **info): if info["_omni_is_prefill"]: offset = info["_omni_num_computed_tokens"] # Select the scheduled slice of the model's prompt embeddings. ... else: # Advance decode-only text/audio state. ... ``` For `preprocess_decode_batch(input_ids=..., req_infos=...)`, each `req_infos` entry contains the same fields, aligned with its corresponding input row. Every such row has `_omni_is_prefill == False`. The optional earlier `preprocess_batch` hook is a separate whole-batch preparation hook; these freshly computed per-row fields are not promised at that earlier boundary. ### Routing guarantee With `has_preprocess=True`, the batched decode hook is eligible only when it is callable, the scheduled span is one token, and `_omni_is_prefill` is false. Otherwise the normal `preprocess` hook runs. The hook's return format depends on whether the runner manages MTP: | Runner capability | `preprocess_decode_batch` return tuple | | --- | --- | | Without `talker_mtp` | `(input_ids, inputs_embeds, updates)` | | With `talker_mtp` | `(input_ids, inputs_embeds, hidden_states, text_step, updates)` | Each tensor's batch dimension and each entry in the `updates` list correspond to one physical request row, including separate CFG branches. Non-MTP batches contain contiguous single-token decode spans; the runner flushes a batch before processing any other span. It copies embeddings into its existing input buffer and applies each update dictionary to the corresponding request. Returning the original `input_ids` object lets it skip an unchanged token-ID copy. The existing five-result MTP format remains supported. Runner-managed MTP is likewise restricted to single-token decode rows. A prefill row, including a one-token tail, is never added to that MTP batch: the runner does not consume its `mtp_inputs`, generate codes for it, or overwrite its preprocessed embedding with an MTP embedding. Decode rows in the same step continue to receive their MTP outputs at their own token offsets. Models may still implement their own model-specific work inside `preprocess`. | Prompt length | Computed before step | Scheduled span | Phase | Runner MTP eligible | | --- | --- | --- | --- | --- | | 5 | 0 | 4 | Prefill | No | | 5 | 4 | 1 | Prefill tail | No | | 5 | 5 | 1 | Decode | Yes | | 5 | 6 | 1 | Decode | Yes | | 1 | 0 | 1 | Prefill | No | | 5 | 5 | 2 | Decode | No (not a single-token span) | ### Compatibility !!! warning "vLLM-Omni 0.20.x and earlier" AR talker models with custom preprocessing that infer phase from span length must use `enable_chunked_prefill: false` for correctness on these releases. They lack the phase metadata and the runner's prefill-tail MTP protection. A model-only heuristic cannot prevent the older runner from overwriting a one-token prompt embedding. Disabling chunking avoids split tails; it does not make span length a reliable phase detector for every possible prompt. The mechanism landed in #3662 (v0.21.0rc2 / v0.22.0 and later); this document formalizes that existing behavior. Prefer upgrading the matching vLLM and vLLM-Omni release pair and consuming the explicit phase keys. Do not silently fall back to `input_ids.numel() > 1` when the model requires chunking correctness. For a custom or backported runtime, verify both metadata and MTP guards. Older runners can restrict `preprocess_decode_batch` to MTP models; a non-MTP model must retain its scalar `preprocess` implementation for those runtimes. ## Correctness and testing plan Runner tests call the production `_preprocess` and `_talker_mtp_forward` methods with CPU buffers and a deterministic MTP model. They inspect kwargs at the model boundary, resulting embeddings, and per-request generated codes. Coverage must include a multi-step prompt split with a one-token tail followed by decode, mixed prefill/decode batches in different orders, normal/batched decode hooks, adjacent decode requests in the same hook call with exact per-request codes, single-token prompts, cached progress, and decode spans longer than one token. No test should pass merely because MTP was disabled for every row. Real-weight validation uses Qwen3-TTS CustomVoice through the full talker and code2wav pipeline. Record the actual scheduled prompt progress, assert that at least one one-token prefill tail was observed, check its hook metadata and MTP exclusion, and require finite non-empty output audio. Compare to an unchunked run with the same real input, greedy sampling, and `VLLM_BATCH_INVARIANT=1`. Greedy sampling alone does not guarantee identical codes across different prefill shapes: floating-point reduction differences can change an argmax and diverge in subsequent autoregressive steps. Invariant kernels keep this exact comparison focused on the phase contract. The two engines must be initialized separately because their chunking settings and token budgets differ. GPU tests belong under `tests/e2e/features/`; existing model tests remain useful adjacent regression coverage. Functional audio checks do not establish throughput or all-model semantic parity; this change makes no performance claim. ## Open questions and discussions There are no new field names or execution modes to choose. Future runner implementations, including Model Runner V2, should preserve these semantics or provide an explicit migration path. Hardware validation beyond the tested platform remains separate from the shared Python routing tests. ## References - [Issue #4382](https://github.com/vllm-project/vllm-omni/issues/4382) - [Original metadata and guards, #3662](https://github.com/vllm-project/vllm-omni/pull/3662) - [Qwen2.5-Omni consumer fix, #5019](https://github.com/vllm-project/vllm-omni/pull/5019) - [Adding an Omni model](../../contributing/model/adding_omni_model.md) - [Test writing guide](../../contributing/ci/test_writing_guide.md)