Merge main and preserve dispatch backlog across monitor windows
Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
commit
ea7de7e3de
769 files changed
+73802
-22805
No files matched your search
@@ -323,7 +323,7 @@ inputs, fields, and ownership rules are described below.
|
||||
flowchart TB
|
||||
layer1["Layer 1 · Authoring inputs<br/>PipelineConfig + DeployConfig"]
|
||||
layer2["Layer 2 · Production resolution boundary<br/>resolve_omni_config()<br/>(StageConfigFactory + VllmOmniConfig internally)"]
|
||||
layer3["Layer 3 · Startup hand-off<br/>OmniConfigResolution<br/>PipelineConfig + temporary OmegaConf stage bridge"]
|
||||
layer3["Layer 3 · Startup hand-off<br/>OmniConfigResolution<br/>PipelineConfig + typed stage configs"]
|
||||
layer4["Layer 4 · Runtime launch planning<br/>StageRuntime"]
|
||||
layer5["Layer 5 · Engine materialization<br/>VllmConfig / OmniDiffusionConfig"]
|
||||
|
||||
@@ -335,10 +335,10 @@ The production resolution boundary is `resolve_omni_config()` in
|
||||
It delegates typed construction to `StageConfigFactory.create_from_model()` and
|
||||
`VllmOmniConfig.from_pipeline_config()`, then returns an
|
||||
`OmniConfigResolution` consumed by both `AsyncOmniEngine` and headless startup.
|
||||
Until `StageRuntime` consumes typed stage configs directly, this envelope carries
|
||||
the effective `PipelineConfig` alongside OmegaConf-compatible `stage_configs` as
|
||||
a temporary runtime bridge; both views describe the same resolved topology,
|
||||
including injected stages.
|
||||
This envelope carries the effective `PipelineConfig` alongside typed
|
||||
`stage_configs`, which `StageRuntime` consumes directly. Both views describe the
|
||||
same resolved topology, including injected stages. Backend arguments are
|
||||
projected from these typed stages when each engine is initialized.
|
||||
|
||||
The legacy `stage_args` YAML path has been removed. Model topology now resolves
|
||||
through `PipelineConfig`, with runtime overrides supplied by `DeployConfig`.
|
||||
@@ -360,7 +360,7 @@ The important ownership rules are:
|
||||
3. CLI and Python overrides are applied at the resolution boundary, with
|
||||
per-stage overrides taking precedence over global values where supported.
|
||||
4. `OmniConfigResolution` is the sole startup hand-off. Its `pipeline_config`
|
||||
and temporary `stage_configs` compatibility view must describe the same
|
||||
and typed `stage_configs` must describe the same
|
||||
topology.
|
||||
5. `StageRuntime` owns launch planning and replica lifecycle. `ReplicaInitPlan`
|
||||
is runtime-private state, not a user configuration object.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Mooncake KV Transfer
|
||||
|
||||
## PR summary
|
||||
|
||||
This change adds an opt-in AR-to-DiT KV handoff using vLLM's native
|
||||
`KVTransferConfig`, Scheduler-owned diffusion KV pages, and
|
||||
`MooncakeConnector`. The existing `OmniKVTransferManager` path remains the
|
||||
fallback when `kv_transfer_config` is absent.
|
||||
|
||||
The orchestrator creates one transfer ticket per request and resolves the
|
||||
bound AR replica's Mooncake endpoint after generation. DiT allocates the
|
||||
destination pages, waits for every worker rank to finish loading, and releases
|
||||
the source and destination pages only after the connector lifecycle completes.
|
||||
Request registration failures are rolled back before Worker dispatch. Receive
|
||||
timeouts and cancellation terminate the request without recycling in-flight
|
||||
pages: the scheduler retains them until every CFG row has completed on every
|
||||
rank. Late completion is polled only while such requests remain. If a peer
|
||||
never completes, its pages remain reserved until shutdown; other requests can
|
||||
continue using the remaining capacity. Invalid pages, missing Worker ranks,
|
||||
and failures without a proven rollback still stop the replica before teardown.
|
||||
The example sets `transfer_timeout: 60.0` seconds explicitly. Cancellation
|
||||
queued during a synchronous receive is processed when that receive completes
|
||||
or times out; it does not interrupt the remote write.
|
||||
|
||||
## Reference configuration
|
||||
|
||||
| Stage | Replicas | Devices | Parallelism | KV cache |
|
||||
| --- | ---: | --- | --- | ---: |
|
||||
| AR | 2 | 0-3 | TP=2 per replica | 2 GiB per worker |
|
||||
| DiT | 2 | 4-7 | TP=2 per replica, EP enabled | 1 GiB per worker |
|
||||
|
||||
The acceptance workload uses 512x512 images, two denoising steps, guidance
|
||||
scale 2.0, and `max_num_seqs=1`.
|
||||
|
||||
For a four-GPU debug run, set both stages to one replica and assign AR TP=2 to
|
||||
devices `0,1` and DiT TP=2 to devices `2,3`.
|
||||
|
||||
## Experiment
|
||||
|
||||
The final comparison used one node with eight NVIDIA RTX PRO 6000 Blackwell
|
||||
Server Edition GPUs, TCP Mooncake transport, the same 50-prompt/seed T2I
|
||||
dataset, concurrency 8, and two independent 100-request runs per
|
||||
implementation. Each side completed 200/200 requests without an OOM or request
|
||||
failure.
|
||||
|
||||
| Implementation | Run 1 (req/s) | Run 2 (req/s) | Mean (req/s) |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| `OmniKVTransferManager` | 4.4103 | 4.4497 | 4.4300 |
|
||||
| Native `MooncakeConnector` | 4.4457 | 4.4699 | 4.4578 |
|
||||
|
||||
Native throughput is **0.63% higher** than the legacy mean. The absolute
|
||||
performance difference is below the 1% acceptance threshold.
|
||||
|
||||
For accuracy, a non-zero transferred prefix and the resulting attention output
|
||||
were compared bit-for-bit with the legacy manager path:
|
||||
|
||||
```text
|
||||
prefix_bits_equal True
|
||||
attention_bits_equal True
|
||||
max_abs_diff 0.0
|
||||
```
|
||||
|
||||
The one-AR/one-DiT TP=2 debug topology also completed 20/20 sequential
|
||||
requests. A separate real-TCP transport check transferred both CFG rows from a
|
||||
TP=1 producer to a strict-Ulysses SP=2 consumer with exact values on both
|
||||
target ranks.
|
||||
|
||||
## Scope and limitations
|
||||
|
||||
- The fully validated end-to-end topology is 2x AR TP=2 plus 2x DiT TP=2.
|
||||
- Heterogeneous TP=1 to SP=2 reshaping is transport-tested, but arbitrary mixed
|
||||
TP/CFG/SP end-to-end combinations have not been exhaustively validated.
|
||||
- CFG-parallel end-to-end deployment is not part of this acceptance run.
|
||||
- Native AR-to-DiT transfer currently requires `async_chunk: false`.
|
||||
- TCP is covered by the acceptance run. The supplementary single-node RDMA
|
||||
measurements use a separate short workload; they do not establish RDMA
|
||||
support for multi-node deployment, which remains out of scope.
|
||||
- The native path currently validates the two-stage `AR(0) -> DiT(1)` graph. Other stage graphs are rejected during startup until
|
||||
their producer/consumer edge mapping is implemented.
|
||||
- Sleep mode is disabled for the native paged path because registered KV pages
|
||||
must remain mapped for the connector lifetime.
|
||||
|
||||
- Ascend NPU adaptation and its performance validation are maintained in a
|
||||
separate follow-up PR; the GPU acceptance results above do not validate NPU.
|
||||
- The homogeneous piecewise CUDA batching optimization is retained. A native
|
||||
paged-attention CUDA graph capture/replay regression covers two CFG rows,
|
||||
strided QKV, mixed causal/image spans, changed inputs, and output stability.
|
||||
It must be run on CUDA hardware; CPU unit tests do not establish graph or
|
||||
end-to-end performance acceptance.
|
||||
@@ -84,10 +84,22 @@ Block topology comes from the pipeline `OffloadPlan` first: `block_attrs` maps
|
||||
each DiT path to its ordered block containers, `encoder_block_attrs` declares
|
||||
streamable encoder stacks, `on_demand_component_paths` marks pipeline-managed
|
||||
residency, `resident_dit_paths` marks the DiTs that may hold resident layers,
|
||||
and `encoder_dlo_weight_replication` marks the encoders whose loader-produced
|
||||
weights are safe for AllGather. DiTs absent from the plan fall back to
|
||||
`_layerwise_offload_blocks_attrs` (including the deprecated singular-name
|
||||
compatibility path).
|
||||
`encoder_dlo_weight_replication` marks the encoders whose loader-produced
|
||||
weights are safe for AllGather, and `offload_submodules` maps a DiT submodule
|
||||
that owns its residency to its own block container. DiTs absent from the plan
|
||||
fall back to `_layerwise_offload_blocks_attrs` (including the deprecated
|
||||
singular-name compatibility path).
|
||||
|
||||
An undeclared DiT submodule joins the plan only when it is large enough that
|
||||
keeping it resident would defeat streaming; its block container is then found
|
||||
by scanning well-known attribute names, which warns once per submodule class.
|
||||
Size is computed from parameter shapes and dtypes, including meta parameters,
|
||||
so resolving before mmap loading preserves the same residency decision.
|
||||
Block ownership validation includes nested components: a block shared with a
|
||||
parent, sibling, or encoder is rejected before any hooks or storage are changed.
|
||||
A submodule with no block container must implement the `load_to_device` /
|
||||
`offload_to_cpu` lifecycle, and the resolver rejects it before any component of
|
||||
the pipeline is placed or hooked.
|
||||
|
||||
The resolver is pure: it moves no tensor, installs no hook, writes no module
|
||||
attribute, and reads no process group — multi-rank facts come from
|
||||
|
||||
@@ -318,18 +318,18 @@ The same behavior applies to the Base and VoiceDesign Qwen3-TTS checkpoints
|
||||
because they use the same Talker implementation.
|
||||
|
||||
!!! warning
|
||||
Do not pass `--no-async-chunk` or enable prefix caching when you want this
|
||||
optimization. Either change causes the runner to fall back to synchronous
|
||||
output construction. Enabling async chunk alone cannot activate the feature
|
||||
for a model that has not opted into async Omni output.
|
||||
Do not pass `--no-async-chunk` when you want this optimization. Disabling
|
||||
async chunk causes the runner to fall back to synchronous output
|
||||
construction. Enabling async chunk alone cannot activate the feature for a
|
||||
model that has not opted into async Omni output.
|
||||
|
||||
!!! note "Prefix cache compatibility"
|
||||
Async Omni output materialization and Omni prefix caching cannot currently
|
||||
run together. `_should_use_async_omni_output()` returns `False` whenever
|
||||
`self.omni_prefix_cache` is present, so prefix caching selects synchronous
|
||||
output materialization. Supporting both features requires snapshotting or
|
||||
otherwise synchronizing prefix-cache merge and update state before the
|
||||
background output path can consume it safely.
|
||||
Prefix cache and async Omni output can run together. `save_outputs` returns
|
||||
a `step_id`; the output builder later calls `materialize(sid, req_ids)` with
|
||||
the save-time request list, which may be a step late relative to the live
|
||||
`input_batch`. `_should_use_async_omni_output()` does not disable itself
|
||||
when the cache is present. See
|
||||
[Automatic Prefix Caching](prefix_caching.md#implementation).
|
||||
|
||||
## Compatibility and Fallbacks
|
||||
|
||||
@@ -341,10 +341,9 @@ runtime conditions hold:
|
||||
| AR async scheduling is enabled | The optimization relies on the scheduler advancing while the prior output is materialized |
|
||||
| `async_chunk` is enabled | The feature targets incremental downstream Omni payloads |
|
||||
| The model stage opts in with `use_async_omni_output` | Models must declare that their output lifecycle is safe to defer |
|
||||
| Omni prefix cache is disabled | Prefix-cache merge and update ordering currently requires synchronous materialization |
|
||||
| Speculative decoding is disabled | Speculative output state is not included in this deferred path |
|
||||
| Routed-expert output is disabled | Routed-expert extraction currently requires the synchronous path |
|
||||
| Postprocess is absent or explicitly runs eagerly | State needed by the next decode step must be updated before the runner returns |
|
||||
| Postprocess is absent or explicitly runs eagerly | State needed by the next decode step must be updated before the runner returns. Eager postprocess sees the scheduled slice (`None, None` combined tensors), not the prefix-cache-merged full prompt; it must only consume the tail |
|
||||
|
||||
These checks are evaluated per stage on runners based on `GPUARModelRunner`.
|
||||
An unsupported combination does not prevent serving; it only falls back to
|
||||
@@ -380,6 +379,8 @@ so Omni payload materialization remains synchronous.
|
||||
- `tests/worker/test_gpu_ar_model_runner.py`: Snapshot, guard, connector
|
||||
ordering, and background error-propagation tests.
|
||||
- [Async Chunk](async_chunk.md): Inter-stage chunking and scheduling design.
|
||||
- [Automatic Prefix Caching](prefix_caching.md#implementation): Prefix-cache
|
||||
`step_id` / `materialize` contract used by the deferred output builder.
|
||||
- [Qwen3-Omni optimization blog](https://vllm.ai/blog/2026-07-01-qwen3-omni-optimization):
|
||||
Optimization context and controlled performance results.
|
||||
- [PR #4476](https://github.com/vllm-project/vllm-omni/pull/4476): Feature
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Automatic Prefix Caching in Omni Models
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
@@ -9,6 +8,8 @@
|
||||
- [High-Level Approach](#high-level-approach)
|
||||
- [Example](#example)
|
||||
- [What About Multimodal Inputs?](#what-about-multimodal-inputs)
|
||||
- [Implementation](#implementation)
|
||||
- [Related Files](#related-files)
|
||||
|
||||
---
|
||||
|
||||
@@ -27,6 +28,7 @@ vLLM implements automatic prefix caching for managing its kv-cache, which is bes
|
||||
This document describes vLLM-Omni's mechanism for caching tensor outputs that are meant to be passed between stages, when requests have common prefixes, similar to the way in which vLLM has prefix caching for the kv-cache. This works in conjunction with vLLM's multimodal encoder caching, but is distinct. See the final section for a concrete example for how they tie together in practice.
|
||||
|
||||
### High-Level Approach
|
||||
|
||||
!!! note "Note 2"
|
||||
Prior to reading this section, it's recommended to take a look at the design documents in vLLM for [Automatic Prefix Caching](https://docs.vllm.ai/en/latest/features/automatic_prefix_caching/), which will make some of the concepts more clear.
|
||||
|
||||
@@ -38,8 +40,10 @@ The main focus of vLLM-Omni's approach to prefix caching stage outputs is to bui
|
||||
|
||||
With this in mind, consider the set of blocks in a 2D layout, where the row represents the index of blocks being considered, and the columns represent the slots corresponding to tokens within each block. Since we know the `num_blocks` and `block_size` from our kv cache config, if we want to cache a tensor with feature size `D`, we can preallocate a CPU tensor of size `(num_blocks, block_size, D)`, and use the same block index and slot mapping to retrieve the corresponding feature vector.
|
||||
|
||||
Host footprint: each cached key costs `num_blocks × block_size × D × dtype_bytes` of **pinned** CPU memory (pinned so device→host can overlap compute), allocated on the first `save_outputs` that sees the key — the first real request pays the `cudaHostAlloc`. Measured on a Qwen3-Omni deployment: thinker `__hidden_states__` `[15092, 16, 2048]` bf16 ≈ 0.92 GiB (plus the same again for each `hidden_states.layer_*` key a model exposes), Qwen3-TTS talker `codes.audio` `[16180, 16, 16]` ≈ 33 MiB. Budget host RAM for the stage accordingly.
|
||||
|
||||
### Example
|
||||
|
||||
!!! note "Note 3"
|
||||
Prefix caching in vLLM-Omni currently is only supported on AutoRegressive stages with one kv-cache group. Configure it with the pipeline-wide `enable_prefix_caching` field in the deploy config.
|
||||
|
||||
@@ -56,7 +60,7 @@ The prefix cache flow is then outlined below.
|
||||
|
||||
2. Say we process the request `The quick brown fox was tired and slept beneath the shady tree`, which is 12 tokens and evenly divides into 3 blocks as shown below.
|
||||
|
||||
```
|
||||
```text
|
||||
[ The quick brown fox ] [ was tired and slept ] [beneath the shady tree ]
|
||||
Block 1: |<--- block tokens ---->|
|
||||
Block 2: |<------- prefix ------>| |<--- block tokens --->|
|
||||
@@ -65,10 +69,9 @@ Block 3: |<------------------ prefix -------------------->| |<--- block tokens -
|
||||
|
||||
When the request processes, we inspect the multimodal outputs and identify the `mm_feature` tensor, which will be of shape `(seq_len, feature_dim)`, i.e., `(12, 16)` in this example. We note that the first axis is dependent on the `seq_len` and add a new cache_tensor of shape `(num_blocks, block_size, feature_dim)` to our multimodal cache for tensors.
|
||||
|
||||
1. If we lay out the cache as a 2D tensor of shape (`num_blocks`, `block_size`), we'll have something like the following:
|
||||
|
||||
3. If we lay out the cache as a 2D tensor of shape (`num_blocks`, `block_size`), we'll have something like the following:
|
||||
|
||||
```
|
||||
```text
|
||||
0: [ The quick brown fox ]
|
||||
1: [ was tired and slept ]
|
||||
2: [beneath the shady tree ]
|
||||
@@ -78,7 +81,8 @@ When the request processes, we inspect the multimodal outputs and identify the `
|
||||
```
|
||||
|
||||
Or, if we flatten it down to 1D,
|
||||
```
|
||||
|
||||
```text
|
||||
0: The
|
||||
1: quick
|
||||
2: brown
|
||||
@@ -90,7 +94,8 @@ Or, if we flatten it down to 1D,
|
||||
```
|
||||
|
||||
which we can think of as row indices into the hidden states tensor if we view it as the 2D shape `(num_blocks x block_size, feature_dim)`. That is, the analogous flattened (from 3D -> 2D) mapping of the cache for hidden states becomes the following.
|
||||
```
|
||||
|
||||
```text
|
||||
0: <hidden states vector of len 2 corresponding to 'The'>
|
||||
1: <hidden states vector of len 2 corresponding to 'quick'>
|
||||
2: <hidden states vector of len 2 corresponding to 'brown'>
|
||||
@@ -103,10 +108,9 @@ which we can think of as row indices into the hidden states tensor if we view it
|
||||
|
||||
Similarly, for the multimodal outputs cache, the flattened coordinates are the same, but the `mm_feature` maps to vectors of length `16` instead of the hidden size of `2`. Note that in practice, we may have multiple multimodal output tensors per forward pass, which may have different names and different feature dimensions.
|
||||
|
||||
1. Now, say that we receive a new request `The quick brown fox jumped over the dog`.
|
||||
|
||||
4. Now, say that we receive a new request `The quick brown fox jumped over the dog`.
|
||||
|
||||
```
|
||||
```text
|
||||
[ The quick brown fox ] [ jumped over the dog ]
|
||||
Block 1: |<--- block tokens ---->|
|
||||
Block 2: |<------- prefix ------>| |<--- block tokens --->|
|
||||
@@ -116,7 +120,7 @@ Here, we will have a cache hit for `Block 1` which will be detected by vLLM base
|
||||
|
||||
Since we have the block indices / slot mappings from the kv cache manager, we can simply mirror the mappings and leverage the same indices for the cached hidden states and multimodal outputs. This allows us to look up the correct tensors from our externally maintained 3D caches.
|
||||
|
||||
```
|
||||
```text
|
||||
0: [ The quick brown fox ] < already in the cache
|
||||
1: [ was tired and slept ]
|
||||
2: [beneath the shady tree ]
|
||||
@@ -129,11 +133,11 @@ Since we have the block indices / slot mappings from the kv cache manager, we ca
|
||||
|
||||
Finally, to pass the full hidden states and multimodal outputs to the next stage, we simply concatenate the cached contents with the corresponding new tensors computed from the current forward call.
|
||||
|
||||
|
||||
### What About Multimodal Inputs?
|
||||
|
||||
It's also useful to consider the case about how Omni prefix caching is handled when we have multimodal inputs that don't cleanly end on block boundaries, as well as how this works with multimodal encoder caching in vLLM. For example:
|
||||
|
||||
```
|
||||
```text
|
||||
[ Im0 Im1 Im2 Im3 ] [ Im4 Im5 foo <empty> ]
|
||||
Block 1: |<--- block tokens ---->|
|
||||
Block 2: |<------- prefix ------>| |<--- block tokens --->|
|
||||
@@ -147,9 +151,9 @@ In reality, this isn't a big problem for correctness, because vLLM also maintain
|
||||
- The hash describing the image data starting at position 0 and with length 6
|
||||
- In vLLM's encoder cache, a mapping from the image hash above to the encoder output
|
||||
|
||||
|
||||
To understand what happens, say we get the following input as a second request:
|
||||
```
|
||||
|
||||
```text
|
||||
[ Im0 Im1 Im2 Im3 ] [ Im4 Im5 bar baz ]
|
||||
Block 1: |<--- block tokens ---->|
|
||||
Block 2: |<------- prefix ------>| |<--- block tokens --->|
|
||||
@@ -162,3 +166,162 @@ Because we have multimodal data in a scheduled span that isn't fully precomputed
|
||||
When we pass our multimodal tensors to the language model component in the same stage, we'll then expect the same outputs, because the prefix caching behaviors in vLLM-Omni / vLLM match, so the LLM will use vLLM's KV cache manager's prefix caching to correctly handle the attention information for `Block 1` while calculating the outputs for `Block 2`, giving us the correct results for processing `Block 2` with the context of `Block 1`.
|
||||
|
||||
Finally, we look up the output hidden states/multimodal tensors corresponding to the prefix cache hit `Block 1` and concatenate it with the forward pass result to get the final result, which is expected to be identical to the full hidden states when prefix caching is disabled.
|
||||
|
||||
### Implementation
|
||||
|
||||
The block/slot model is `vllm_omni/core/prefix_cache/`.
|
||||
`OmniPrefixCacheManager` owns slot occupancy, the request-task table,
|
||||
hit spans, and merge.
|
||||
`OmniPrefixCacheController` moves data: a reusable `StagingBufferPool` for
|
||||
this step's device→host copy, and writes into the durable `PrefixBlockPool`.
|
||||
The state lock covers those tables only.
|
||||
|
||||
Miss is not an error (this step's forward slice only). A hit span whose
|
||||
hidden rows are absent is fatal. For mm keys the rule is looser: a model may
|
||||
emit a key only for some requests, so a hit span with no rows behind an mm key
|
||||
reads zeros from the pool for those positions rather than raising. Abort still
|
||||
writes: once a hash entered this step's batch it must land in the cache.
|
||||
|
||||
`enable_prefix_caching` is refused on KV-consumer stages (`kv_role` of
|
||||
`kv_consumer` or `kv_both`). KV received from a producer is reported as
|
||||
`num_computed_tokens` too, and the manager cannot tell it from a local hit.
|
||||
Producer-only stages are unaffected. Pooling stages never save, so they get
|
||||
no cache. This gate is one function, `stage_prefix_cache_config`, called by
|
||||
both the GPU and the NPU model runner at kv-cache init.
|
||||
|
||||
Which stages may set `enable_prefix_caching: true`:
|
||||
|
||||
| Stage | `enable_prefix_caching` | Why |
|
||||
| --- | --- | --- |
|
||||
| AR stage with one full-attention kv group whose hidden states / per-token mm feed the next stage (Qwen3-Omni thinker and talker) | supported | The case the cache is built for: full-prompt hidden states are merged from the pool on a hit. |
|
||||
| AR stage that sets `requires_full_prefix_cached_hidden_states = False`, optionally with `deferred_prefix_cache_mm_keys` (Qwen3-TTS talker, Higgs v3 talker) | supported | Hidden is not cached; deferred codec rows are written once on finish. |
|
||||
| Pooling stage | ignored | Never saves; the gate returns no config. |
|
||||
| `kv_role: kv_consumer` / `kv_both` | refused at kv-cache init (`OmniPrefixCacheUnmatchError`) | Producer KV shows up as `num_computed_tokens` and is indistinguishable from a local hit. |
|
||||
| Speculative decoding on the stage | refused at kv-cache init | Under async scheduling vLLM keeps `num_computed_tokens_cpu` optimistic (all drafts accepted) during the forward and corrects it afterwards; `step_slots_cpu` would mirror rows at the wrong slots. Not verified; refused as a whole. |
|
||||
| `prefix_match_unit` smaller than `block_size` | refused at kv-cache init | Sub-block hits make `num_computed_tokens` unaligned; the hit registry only mirrors whole blocks. |
|
||||
| Hybrid / sliding-window / multi-group kv cache (e.g. a talker with `attention_type: sliding_recompute`) | refused at kv-cache init | The cache mirrors exactly one full-attention block table. |
|
||||
| Attention backend whose kernel block size differs from `--block-size` (FlashInfer / FlashMLA / CutlassMLA with a block size they do not list natively), or decode context parallel | refused at first step (`FullAttentionGroupView`) | `step_slots_cpu` computes `table[req, pos // block_size] * block_size + pos % block_size` over allocator block ids; hybrid kernel blocks and DCP token striping change that row layout. FlashAttention / Triton accept any multiple of 16 and never split blocks. |
|
||||
| Codec decoder / Code2Wav stages (Qwen3-Omni stage 2, Qwen3-TTS stage 1) | keep `false` | Nothing downstream consumes their hidden states; the cache would only add device→host copies. Not validated. |
|
||||
| Diffusion stages | n/a | No vLLM KV cache to mirror. |
|
||||
|
||||
Hit spans come from `scheduled_new_reqs` only, as in the pre-refactor cache:
|
||||
|
||||
- A new request with a (partial) prefix hit is the normal path: the hit
|
||||
blocks are read from the pool, the rest is this step's rows, and the
|
||||
divergent tail is written under its own blocks. The gather starts on the
|
||||
prefetch thread at `new_step_starts`; a same-step hit (vLLM hashes blocks
|
||||
at schedule time, so `b` can hit blocks `a` computes in the same forward)
|
||||
cannot plan until `a`'s write is registered and starts at `save_outputs`
|
||||
instead. Either way it runs before the next step can hand those blocks
|
||||
to a new tenant. vLLM frees blocks one step before `finished_req_ids`
|
||||
arrives, so a finished request whose last step materializes late may
|
||||
still find a hit slot reassigned. Each `(slot, key)` carries a write
|
||||
version, bumped whenever a new write claims it. A planned read captures
|
||||
that version and is registered in `_pending_reads`; a later write that
|
||||
reclaims those slots copy-on-writes the still-`COMMITTED` rows into the
|
||||
ref before it overwrites the pool, so a delayed fetch serves the original
|
||||
tenant. A version mismatch with no preserved copy raises for live and
|
||||
finished alike (the pool rows are a newer tenant's). Production defaults
|
||||
stay opt-in until preempt/resume hit spans are reconstructed.
|
||||
- `async_chunk` continuation: when the next upstream chunk arrives, the same
|
||||
request id re-enters `scheduled_new_reqs` with `num_computed_tokens` equal
|
||||
to what it already computed itself. Ids already in `live_reqs` are skipped
|
||||
for hit marking: those rows were delivered in earlier steps and re-emitting
|
||||
them would duplicate output. A `delivered_upto` span for this case is
|
||||
Phase 2.
|
||||
- Preemption + reschedule: vLLM resets `num_computed_tokens` to 0 on
|
||||
preemption and re-runs prefix matching on resume, so the resumed request
|
||||
can come back with a fresh hit. With the V1 model runner it arrives
|
||||
through `scheduled_cached_reqs` (id in `resumed_req_ids`, `new_block_ids`
|
||||
replaces the table); with the V2 runner it re-enters `scheduled_new_reqs`
|
||||
while still in `live_reqs`. Neither path marks an omni hit span: the
|
||||
resumed request gets only the rows it recomputes, and its still-open
|
||||
deferred write keeps appending (a slot written twice keeps the later
|
||||
chunk). Cache integrity holds either way — the hit blocks already have
|
||||
rows, from this request or the one it hit. Stages that need full prompt
|
||||
hidden states should be sized so preemption does not occur while prefix
|
||||
caching is on. Same as before this refactor; tracked for Phase 2.
|
||||
|
||||
Two write paths, split by `ModelCachePolicy.deferred_keys`:
|
||||
|
||||
- Immediate (`JOIN_NEXT_STEP`): save launches a whole-step device→host into
|
||||
a staging slot; the committer waits that event and writes the CPU pool.
|
||||
The next save waits `done` (the pool write): a reused slot must never
|
||||
leave a pending pool write behind, or a delayed hit read of the old rows
|
||||
would find nothing recoverable.
|
||||
- Deferred (`JOIN_ON_FINISH`): mm whose first dim is this step's token count
|
||||
stays on a per-request GPU clone; `_WriteChunk`s append across steps;
|
||||
finish/abort (or GPU-byte-budget pressure) forces the copy. One open
|
||||
WriteTask per request; a budget flush may close it mid-request, in which
|
||||
case the next save opens a new one (`write_n` + 1) and a hit reads both,
|
||||
so one long request cannot pin the whole budget.
|
||||
|
||||
A `WriteTask` moves through `TaskState` only via `transition()`, one step at
|
||||
a time along a strict chain:
|
||||
`PENDING` (registered, not queued) → `QUEUED` (on a copy queue) →
|
||||
`COPYING` (one thread owns the copy stage) → `HOST_READY` (host rows ready) →
|
||||
`WRITTEN` (in the CPU pool); `FAILED` is reachable from any non-terminal
|
||||
state. Skipping a step raises. The worker claims `COPYING` in the same `_wake`
|
||||
critical section as the queue pop, so `escalate` never re-queues a task it can
|
||||
see is already claimed. `host_ready` / `done` are wait primitives set by the
|
||||
transitions into `HOST_READY` / `WRITTEN` / `FAILED`. GPU-clone bytes are
|
||||
charged once per clone on a `_BudgetTicket` pinned by every task that views
|
||||
it, and uncharged when the last holder releases (idempotent per tid).
|
||||
|
||||
Mm whose first dim equals the unpadded scheduled length *or* the CUDA-graph
|
||||
padded length is registered on first sighting. Talker `codes.audio` is a
|
||||
cat of scheduled rows and stays unpadded while hidden is padded; both must
|
||||
open a pool key, whether immediate or deferred.
|
||||
Leftover mm (lists, `codes.ref`, any tensor whose first dim is not this
|
||||
step's token count) is copied to CPU at save without truncating that first
|
||||
dim. That leftover copy is the async-builder read replica for this step; it
|
||||
does not write the pool or carry abort/preempt occupancy.
|
||||
|
||||
```python
|
||||
cache.register_policy(ModelCachePolicy.from_model(model)) # load_model
|
||||
cache.new_step_starts(scheduler_output) # before _update_states
|
||||
sid = cache.save_outputs(hidden, mm_outputs, num_tokens_unpadded=n,
|
||||
num_tokens_padded=n_pad)
|
||||
outs = cache.materialize(sid, req_ids) # or discard_step(sid)
|
||||
```
|
||||
|
||||
Each step id is consumed exactly once. `req_ids` must be a subset of the save
|
||||
snapshot. At most `staging_depth` unused step ids may exist at once: every
|
||||
`save_outputs` claims one staging slot, including saves with only leftover
|
||||
mm that copy no device→host page. A slot is also held by each immediate
|
||||
write that views it, until the committer's pool write. A later save waits
|
||||
for `materialize`/`discard_step` or that pool write to free a slot;
|
||||
`staging_claim_timeout_s` then errors with the unused ids and the task count.
|
||||
`join`/`join_host_ready` use the same bound and raise with the stuck task's
|
||||
id and state instead of hanging the caller.
|
||||
`materialize` may run on the async output builder after the engine has
|
||||
entered the next step; leftover mm (not written to the pool) is copied
|
||||
to CPU at `save_outputs` so the builder never reads live CUDA-graph
|
||||
buffers. See
|
||||
[Async Omni Output Materialization](omni_async_output_materialization.md).
|
||||
|
||||
The cache is constructed only on the last pipeline-parallel rank
|
||||
(`_ensure_omni_prefix_cache`). Other ranks skip it: they never call
|
||||
`save_outputs`, so a hit table there would resolve to absent slots.
|
||||
|
||||
Threads, locks, and what each may block on:
|
||||
|
||||
| Thread | Role | May block on | Must not hold while blocked |
|
||||
| --- | --- | --- | --- |
|
||||
| Engine | `new_step_starts`, `save_outputs` | previous-step `join` (`done`) / finished-write `join_host_ready`; `reserve()` GPU-byte flush; staging-slot claim; `dispatch()` / finish-abort `escalate()` (eager mode: the copy + pool write run inline) | `_state_lock` |
|
||||
| Async output builder | `materialize` (may overlap the next engine step) | this step's `step_d2h_event`; `join` (`done`); deferred `fetch_host` | `_state_lock` |
|
||||
| Committer | `_worker_loop`: wait device→host / deferred copy / pool write | `_wake.wait`; `step_d2h_event` or copy-stream sync | never takes `_state_lock` |
|
||||
| Prefetch pool | hit-span gather during forward | `join` (`done`); deferred `fetch_host` | `_state_lock` |
|
||||
|
||||
| Lock | Covers | Does not cover |
|
||||
| --- | --- | --- |
|
||||
| manager `_state_lock` | occupancy tables, step contexts, hit spans, task registration, pool-key publish (`install_key`) | join, GPU-byte flush, copy, `step_d2h_event` wait, pool-key allocation, eager `dispatch()` / `escalate()` |
|
||||
| controller `_lock` / `_wake` | task registry, queues, GPU-clone byte budget | device→host / pool-write body (released before `synchronize`) |
|
||||
| `WriteTask.lock` | `state`, `reassigned`, `append_chunk` | waiting on `host_ready` / `done` (those are events) |
|
||||
|
||||
### Related Files
|
||||
|
||||
- `vllm_omni/core/prefix_cache/`
|
||||
- `vllm_omni/worker/gpu_model_runner.py` (`_ensure_omni_prefix_cache`)
|
||||
- `tests/core/test_prefix_cache.py`
|
||||
- [Async Omni Output Materialization](omni_async_output_materialization.md)
|
||||
@@ -271,3 +271,111 @@ The required evidence is:
|
||||
|
||||
Audio that is empty, all zero, non-finite, or only a protocol `listen` event is
|
||||
not a successful end-to-end result.
|
||||
|
||||
### Paced multi-session load driver
|
||||
|
||||
The existing driver keeps its two-session admission and slot-reuse validation
|
||||
when `--sessions` is omitted. Passing `--sessions N` selects a separate,
|
||||
bounded load mode; it does not replace the lifecycle test or change server
|
||||
admission capacity. Start an existing PersonaPlex server configured to admit
|
||||
the requested number of sessions, then run from a source checkout with the
|
||||
vLLM-Omni client dependencies installed:
|
||||
|
||||
```bash
|
||||
python -m tests.e2e.online_serving.personaplex_realtime_duplex \
|
||||
--model nvidia/personaplex-7b-v1 \
|
||||
--input-wav /path/to/input.wav \
|
||||
--sessions 2 --load-frames 1000 --drain-s 2 \
|
||||
--server-revision SERVER_COMMIT --server-hardware A100-80GB \
|
||||
--output-dir /tmp/personaplex-load-n2
|
||||
```
|
||||
|
||||
Use a new output directory for every run. The driver refuses to overwrite an
|
||||
existing `load-result.json`. The server labels are supplied by the operator,
|
||||
not detected or verified by the client. Record the server command, package
|
||||
versions, checkpoint revision and hardware alongside a published measurement.
|
||||
|
||||
All attempted sessions finish admission before admitted sessions share a
|
||||
common input start. A rejected session remains a failed row in the report;
|
||||
it is not removed from the denominator. Each session receives at most
|
||||
`--load-frames` input frames, without looping the WAV, and uses 80 ms pacing.
|
||||
A stalled send cannot cause a catch-up burst. The timeline records send
|
||||
schedule lateness so an overloaded client is visible rather than mistaken
|
||||
for a faster server. This mode measures steady concurrency, not join/leave
|
||||
interference; the original lifecycle mode still checks slot reuse.
|
||||
Load mode does not probe an additional N+1 session for `resource_exhausted`;
|
||||
the lifecycle mode owns that admission-limit check. A per-session
|
||||
`client_pacing_warning` and the summary's `client_pacing_warning_sessions`
|
||||
identify send-lateness p99 above one nominal frame (80 ms). This is a warning,
|
||||
not an extra acceptance gate: read it alongside server-facing measurements
|
||||
so a slow client is not mistaken for a slow server.
|
||||
|
||||
The fixed drain window is part of the measurement configuration. Audio
|
||||
arriving during cleanup is excluded, so closing cannot rescue a session that
|
||||
missed its observation deadline. Cleanup is attempted for admitted sessions
|
||||
even when audio validation fails. Protocol errors, missing/invalid audio,
|
||||
shared response IDs and cleanup failures make the report fail and the CLI
|
||||
exit nonzero. Cancellation closes transports and propagates to the caller.
|
||||
`--cleanup-timeout-s` (default 5 s) bounds session-close send and acknowledgement
|
||||
together, independently of `--timeout-s`. The subsequent WebSocket close
|
||||
handshake has the same separate bound; an unresponsive close acknowledgement
|
||||
cannot hold cancellation for the full inference timeout.
|
||||
|
||||
`load-result.json` retains per-session send and audio-packet timelines using
|
||||
the client's monotonic clock. It does not export handshake credentials or
|
||||
raw audio payloads. Metric definitions are explicit:
|
||||
|
||||
Both modes validate each nonempty audio packet's strict base64 encoding, PCM16
|
||||
sample alignment, 24 kHz sample rate and response identity. Valid aggregate
|
||||
audio cannot hide a malformed packet. In load mode, rejected packets make the session fail
|
||||
and appear in `invalid_audio_packets` with their event index, receive time and
|
||||
fixed diagnostic code; they do not contribute samples or timing intervals.
|
||||
Empty audio deltas remain legal and contribute no samples. This check does
|
||||
not change the public client's decoding. The lifecycle scenario and dispatch
|
||||
remain the same, but malformed packets that aggregate checks once tolerated
|
||||
now fail that mode as well.
|
||||
|
||||
The probe accepts both the current OpenAI Realtime audio event name
|
||||
`response.output_audio.delta` and the legacy `response.audio.delta`. Validation
|
||||
uses the wire payload directly rather than relying on a particular client
|
||||
library's alias table, so a server-side event-name migration cannot silently
|
||||
turn real audio into a zero-output measurement.
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `client_audio_packet_interval_ms` | Consecutive nonempty packet arrivals: median, linear p99, max, and zero-based index of the first maximal interval. Interval index 0 ends at packet 1. One packet can contain multiple codec frames; this is not server tick latency. |
|
||||
| `client_first_audio_after_stream_start_ms` | First audio receipt minus first input send start; admission is excluded. |
|
||||
| `client_stream_rtf` | First input send start to last audio receipt, divided by received audio duration. This includes real-time input pacing and is not an inference-only RTF. |
|
||||
| `client_send_lateness_ms` | Send start minus its original shared schedule, clipped at zero. |
|
||||
| `frame_deficit` | Integer input-frame minus output-frame count from the shared session validation; missing when valid whole-frame accounting is unavailable. |
|
||||
| `output_frames`, `voiced_frames`, `silent_frames`, `frame_coverage_ratio`, `audio_rms`, `audio_chunks` | Session-validation statistics retained on success and on acceptance failure once computed. |
|
||||
| `acceptance_check` | Failed local check, with numeric diagnostics in `error`; other exceptions retain phase/type only, without remote handshake or metadata text. |
|
||||
| `audio_underrun_s`, `audio_underrun_event_count`, `audio_continuity_ok` | Shared `compute_continuity_stats` on valid PCM16 packet arrivals, with its default 100 ms underrun threshold; absent audio gives missing values rather than a continuity pass. |
|
||||
|
||||
No output gives missing latency/RTF values, not zero. By default RTF is
|
||||
diagnostic; `--max-client-rtf` adds an explicit ceiling. The existing frame
|
||||
coverage, audible-frame and scheduler-data-plane checks still apply. A longer
|
||||
`--drain-s` can demonstrate eventual completion under backlog, but it must not
|
||||
be reported as real-time capacity when the fixed-window run failed or client
|
||||
RTF exceeded the stated target. Likewise, the first live session can pay
|
||||
one-time model/codec/JIT costs; either use the server's configured duplex
|
||||
warmup path or label that run as cold-start evidence before comparing steady
|
||||
capacity. Passing client-only metrics does not certify model quality or a
|
||||
real-time capacity target on another GPU. Begin with N=1 and N=2; higher N is a
|
||||
capacity experiment, not a promised property of an A100 or of this patch.
|
||||
Continuity is a client-arrival diagnostic, not proof of speaker playback and
|
||||
not an additional pass/fail gate. Packet-interval and send-lateness summaries
|
||||
retain their documented linear p99; the older client interval helper reports
|
||||
rounded nearest-rank p95 instead and is not substituted for that schema.
|
||||
|
||||
Deterministic driver and localhost WebSocket checks need no model or GPU:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/e2e/online_serving/test_personaplex_load_driver.py \
|
||||
-m 'core_model and cpu' --run-level core_model -q
|
||||
```
|
||||
|
||||
These checks test the driver against controlled protocol responses. Run the
|
||||
load-driver command against real weights separately before reporting model-serving
|
||||
performance. Server tick timestamps and generic TTFT/TPOT aggregation remain
|
||||
the responsibility of the existing duplex metrics paths, not this driver.
|
||||
+82
-14
@@ -9,6 +9,12 @@ the owner document for `vllm_omni/engine/duplex/**`,
|
||||
`vllm_omni/entrypoints/duplex_omni.py`, `vllm_omni/entrypoints/duplex/**`,
|
||||
`vllm_omni/clients/**` and the per-model `duplex/` packages.
|
||||
|
||||
`vllm_omni/protocol/realtime/**` (the OpenAI Realtime API) and
|
||||
`vllm_omni/protocol/duplex/**` (vLLM-Omni's extension of it) are the shared
|
||||
wire codec duplex is built on. Duplex is its first consumer but not its owner: the codec
|
||||
is deliberately runtime-agnostic so a second Realtime surface can reuse it
|
||||
(RFC #6592 P0a).
|
||||
|
||||
The public wire contract lives in
|
||||
[`docs/serving/realtime_duplex_api.md`](../serving/realtime_duplex_api.md);
|
||||
this page describes how it is produced.
|
||||
@@ -108,23 +114,56 @@ plugin and the session runtime config to `DuplexOrchestrator` directly.
|
||||
`AsyncOmniEngine`, `OrchestratorBase` and `Orchestrator` carry only
|
||||
template seams; `tests/engine/test_duplex_import_boundary.py` checks that
|
||||
importing the turn-based stack loads no duplex module.
|
||||
4. **Every surface a duplex server exposes is backed by a session.**
|
||||
4. **Deployment selects the serving stack.**
|
||||
`vllm-omni serve` constructs `DuplexOmni` when the pipeline declares
|
||||
`duplex_plugin`; the server exposes `/v1/realtime?duplex=1` (alias
|
||||
`duplex_plugin` and the deploy configuration sets `session_mode: duplex`.
|
||||
An explicit `session_mode: turn` selects `AsyncOmni` instead, without
|
||||
changing the pipeline's duplex capability. In duplex mode the server exposes `/v1/realtime?duplex=1` (alias
|
||||
`/v1/duplex`), `POST /v1/chat/completions`, `/v1/models` and `/health`, and
|
||||
every other turn-based route reports "not available". The chat route is
|
||||
served by `DuplexChatCompletionsAdapter`, a Realtime client that runs one
|
||||
short-lived session per request: speech becomes a committed turn, text is
|
||||
seeded as the session's opening turn because a model-native model takes a
|
||||
turn only when it hears speech. Whether a model can be reached with text is
|
||||
`DuplexCapabilities.supports_chat_completions`; one that cannot is refused
|
||||
at once rather than left to idle out. Turn-based use of the same model stays
|
||||
available offline through `Omni` / `AsyncOmni`.
|
||||
every other turn-based route reports "not available". The chat route uses
|
||||
the ordinary chat service on the duplex engine, without a session, when
|
||||
the plugin declares `DuplexCapabilities.supports_chat_completions`.
|
||||
Turn-based deployments use the ordinary API initialization and retain
|
||||
model capability checks and endpoint restrictions.
|
||||
5. **Serving is transport only.** The websocket handler does socket I/O,
|
||||
wire-envelope validation, command translation, event rendering and the
|
||||
attachment/resume/replay bookkeeping; it holds no session state.
|
||||
6. **No `typing.Protocol`** in the duplex surfaces: the plugin, data plane,
|
||||
session state, PCM buffer, stage port and client transport seams are ABCs.
|
||||
7. **The wire codec is not duplex, and a wire type is not a mailbox message.**
|
||||
The event and command *vocabulary* plus its wire rendering, the audio format
|
||||
negotiation, the conversation-item rules and the error envelope are model-
|
||||
and runtime-agnostic, and split along the three tiers
|
||||
[`docs/serving/realtime_duplex_api.md`](../serving/realtime_duplex_api.md)
|
||||
already defines: **Tier 1** (identical to OpenAI) is
|
||||
`vllm_omni/protocol/realtime/**`; **Tier 2** (OpenAI names carrying our
|
||||
extensions) and **Tier 3** (ours alone) are `vllm_omni/protocol/duplex/**`.
|
||||
A Tier 2 class *subclasses* its Tier 1 twin and declares only what it adds,
|
||||
so the OpenAI surface stays honestly pure --- `session.created` without
|
||||
`resume_token`, `input_audio_buffer.append` without `video_frames`. Our
|
||||
error **codes** are Tier 3 (`protocol/duplex/errors.py`); only the envelope
|
||||
shape and OpenAI's three `error.type` classes are Tier 1.
|
||||
What stays engine-side is everything that is *internal representation*
|
||||
rather than contract: `DuplexCommand.payload()` renders the session runner's
|
||||
mailbox dictionary, whose channel genuinely differs from the client event
|
||||
(`session.update` and the three `conversation.item.*` commands all travel on
|
||||
`turn.signal`); `engine/duplex/realtime_commands.py` decides which command a
|
||||
decoded event becomes; `engine/duplex/realtime_events.py` holds the
|
||||
session's projection state. The codec may not import the engine, the
|
||||
entrypoints, `model_executor` or the clients
|
||||
(`tests/protocol/realtime/test_protocol_import_boundary.py`), and there is
|
||||
exactly one implementation of each codec behaviour
|
||||
(`tests/protocol/realtime/test_realtime_codec_single_source.py`).
|
||||
The dependency chain is `protocol/realtime` <- `protocol/duplex` <- engine /
|
||||
entrypoints / clients, and a duplex consumer uses **only** the middle link:
|
||||
`protocol/duplex/**` re-exports the Tier 1 names it does not extend, so a
|
||||
helper that later needs a duplex-specific version (`convert_input_audio_with_rate`
|
||||
resamples to MiniCPM-o's 16 kHz rather than the client's rate) is overridden
|
||||
in one file instead of at every call site
|
||||
(`tests/protocol/duplex/test_duplex_protocol_facade.py`).
|
||||
`DuplexEvent` is therefore a plain alias of `RealtimeEvent` --- an event has
|
||||
no engine-internal half --- while `DuplexCommand` is a real class, because a
|
||||
command does.
|
||||
|
||||
## Package layout
|
||||
|
||||
@@ -143,6 +182,31 @@ vllm_omni/
|
||||
│ │ ├── chat_completions.py DuplexChatCompletionsAdapter (/v1/chat/completions on a session per request)
|
||||
│ │ └── websocket.py websocket send/close/receive helpers
|
||||
│ └── openai/api_server.py builds DuplexOmni for duplex models; session-backed app state
|
||||
├── protocol/ SHARED WIRE CODEC (no engine / no model / no transport)
|
||||
│ ├── realtime/ the OpenAI Realtime API itself
|
||||
│ │ ├── events.py RealtimeEvent + OpenAI's 30 server events (22 ship as Tier 1;
|
||||
│ │ │ 8 have a Tier 2 twin in protocol/duplex)
|
||||
│ │ ├── commands.py RealtimeCommand + OpenAI's 10 client commands (8 Tier 1,
|
||||
│ │ │ 2 with a Tier 2 twin) --- wire_type + fields, no payload()
|
||||
│ │ ├── formats.py audio format spellings, what is supported, per-session validation
|
||||
│ │ ├── session.py session-object readers; RealtimeInputDefaults (append defaults)
|
||||
│ │ ├── items.py conversation item shape, truncation, transcripts, camera frames
|
||||
│ │ ├── audio_input.py decode_audio_append -> RealtimeAudioAppend; client speech hints
|
||||
│ │ ├── audio.py PCM / G.711 / WAV decode, resample, re-encode
|
||||
│ │ ├── errors.py RealtimeProtocolError (the envelope's exception type)
|
||||
│ │ └── capabilities.py RealtimeProtocolCapabilities: what one consumer can serve
|
||||
│ └── duplex/ Tier 2 + Tier 3, and the one door a duplex consumer uses
|
||||
│ ├── __init__.py re-exports the Tier 1 helper functions (audio, formats,
|
||||
│ │ items, session, capabilities) so the engine never
|
||||
│ │ imports protocol/realtime directly
|
||||
│ ├── events.py the whole 42-event vocabulary: 22 Tier-1 re-exports,
|
||||
│ │ 8 Tier-2 subclasses (session.created + resume_token, ...),
|
||||
│ │ 12 Tier-3 events (listen/speak, playback, resume, ...);
|
||||
│ │ DuplexEvent = RealtimeEvent
|
||||
│ ├── commands.py the whole 17-command vocabulary: 8 Tier-1 re-exports,
|
||||
│ │ 2 Tier-2 subclasses (append + hints, commit + final),
|
||||
│ │ 7 Tier-3 commands (barge_in, playback.ack, ...)
|
||||
│ └── errors.py our error-code vocabulary (Tier 3) + RealtimeProtocolError
|
||||
├── engine/
|
||||
│ ├── omni_engine_base.py OmniEngineBase (stage processes, orchestrator thread, queues, RPC)
|
||||
│ ├── async_omni_engine.py AsyncOmniEngine (turn-based request building)
|
||||
@@ -150,16 +214,20 @@ vllm_omni/
|
||||
│ ├── orchestrator.py OrchestratorBase + Orchestrator
|
||||
│ ├── duplex_orchestrator.py DuplexOrchestrator (+ DuplexOrchestratorRequestState; implements DuplexStagePort)
|
||||
│ └── duplex/
|
||||
│ ├── commands.py DuplexCommand dataclasses, command_from_realtime
|
||||
│ ├── realtime_commands.py Realtime client event -> DuplexCommand translation
|
||||
│ ├── events.py DuplexEvent dataclasses (+ to_realtime), REALTIME_ERROR_TYPES_BY_CODE
|
||||
│ ├── commands.py mailbox half: DuplexCommand.payload() + the mailbox `type`,
|
||||
│ │ paired with each protocol command; command_from_realtime
|
||||
│ ├── realtime_commands.py duplex binding of the codec: decoded event -> DuplexCommand,
|
||||
│ │ DUPLEX_REALTIME_CAPABILITIES, duplex_response_format
|
||||
│ ├── events.py re-export shim (DuplexEvent = RealtimeEvent) + the runner's
|
||||
│ │ epoch-filter sets DOMAIN_TERMINAL_EVENTS / MODEL_OUTPUT_EVENTS
|
||||
│ ├── realtime_events.py RealtimeProjectionState: internal event -> typed events
|
||||
│ ├── messages.py queue envelopes (Open/Close/Resume/Touch/Command/Result/Event), DuplexSessionError
|
||||
│ ├── config.py DuplexSessionConfig, DuplexCapabilities, ResponseCreateOptions
|
||||
│ ├── contracts.py DuplexFence (session_id, epoch, turn_id), stage request records, DuplexStagePort
|
||||
│ ├── plugin.py DuplexModelPlugin, DuplexModelSessionState, DuplexDataPlane, PcmAppendBuffer ABCs
|
||||
│ ├── turn_detection.py server-side VAD turn detector used by the session
|
||||
│ ├── audio.py / vad.py / intermediate.py
|
||||
│ ├── audio.py compatibility re-export, via protocol/duplex
|
||||
│ ├── vad.py / intermediate.py
|
||||
│ └── session/ one engine-resident session and everything that runs it
|
||||
│ ├── engine_session.py DuplexEngineSession: ledgers, lease, fence, stage resources, append sequencing
|
||||
│ ├── runner.py DuplexSessionRunner (per-session mailbox on the orchestrator loop)
|
||||
|
||||
@@ -30,6 +30,7 @@ primary_code_paths:
|
||||
- vllm_omni/entrypoints/utils.py
|
||||
- vllm_omni/entrypoints/duplex/**
|
||||
- vllm_omni/clients/**
|
||||
- vllm_omni/protocol/**
|
||||
primary_path_exceptions:
|
||||
- path: vllm_omni/entrypoints/openai/errors.py
|
||||
owner: error_contracts.md
|
||||
@@ -53,6 +54,7 @@ depends_on:
|
||||
- error_contracts.md
|
||||
- engine_orchestration.md
|
||||
validation_paths:
|
||||
- tests/protocol/**
|
||||
- tests/entrypoints/test_omni_entrypoints.py
|
||||
- tests/entrypoints/test_async_omni.py
|
||||
- tests/entrypoints/test_async_omni_pause_sleep_routing.py
|
||||
@@ -94,11 +96,43 @@ This document owns offline API semantics, CLI and serve composition,
|
||||
supported OpenAI-compatible routes, request validation and normalization,
|
||||
response conversion, streaming/session behavior, and engine handoff.
|
||||
|
||||
When `--api-server-count` is greater than one, the serve composition root
|
||||
starts the frontend process manager while a parent-owned `StageRuntime`
|
||||
launches the shared local stage engines. Each frontend receives only its own
|
||||
stage-client channel configuration and does not launch or retire backend
|
||||
processes. CLI validation rejects unsupported distributed, diffusion,
|
||||
fault-tolerant, elastic-EP, Ray, and runtime-LoRA combinations before startup.
|
||||
|
||||
It does not own configuration precedence, cross-stage routing, stage
|
||||
placement, payload implementation, or semantic error classification.
|
||||
`entrypoints/openai/errors.py` is an explicit primary-path exception owned by
|
||||
`error_contracts.md`.
|
||||
|
||||
It also owns `vllm_omni/protocol/**`, the shared wire codec promoted by RFC
|
||||
[#6592](https://github.com/vllm-project/vllm-omni/issues/6592) P0a. It follows the three tiers
|
||||
`docs/serving/realtime_duplex_api.md` already defines. `protocol/realtime/**`
|
||||
is Tier 1, identical to OpenAI: 30 server events, 10 client commands, their
|
||||
typed fields and pure wire rendering, audio-format negotiation,
|
||||
conversation-item rules and the error-envelope shape. `protocol/duplex/**` is
|
||||
Tier 2 (OpenAI names carrying vLLM-Omni extensions, each a subclass of its
|
||||
Tier 1 twin declaring only what it adds) and Tier 3 (ours alone, including our
|
||||
error-code vocabulary), kept separate so a GA-only consumer is not handed a
|
||||
vocabulary its clients never send. That package sits
|
||||
outside `entrypoints/` on purpose --- the engine depends on it, so it belongs
|
||||
to neither layer --- but deciding what a Realtime client may put on the wire is
|
||||
this document's subject, and the same reasoning already places
|
||||
`vllm_omni/errors.py`, `inputs/` and `outputs/` under contract documents rather
|
||||
than under a consumer.
|
||||
|
||||
It is deliberately **not** owned by `../fullduplex.md`, even though duplex is
|
||||
the codec's only consumer today. RFC #6592 asks for a named owner precisely so
|
||||
a second Realtime consumer does not need duplex review to change the codec
|
||||
(#6592 open question 8). Ownership here is review scope, not a dependency: the
|
||||
codec may not import the engine, the entrypoints or the model code, which
|
||||
`tests/protocol/realtime/test_protocol_import_boundary.py` enforces. When a
|
||||
second consumer lands (#6592 P0b) the package is expected to graduate to its
|
||||
own module document with owners drawn from both consumers.
|
||||
|
||||
## Candidate invariants
|
||||
|
||||
These identifiers are proposals while the document is `draft`.
|
||||
@@ -108,6 +142,22 @@ These identifiers are proposals while the document is `draft`.
|
||||
**Rule:** Entrypoints MUST NOT implement cross-stage routing or stage lifecycle
|
||||
policy.
|
||||
|
||||
### ENTRY-INV-002: The shared Realtime codec does not depend on its consumers
|
||||
|
||||
**Rule:** `vllm_omni/protocol/**` MUST NOT import `vllm_omni.engine`,
|
||||
`vllm_omni.entrypoints`, `vllm_omni.model_executor`, `vllm_omni.worker` or
|
||||
`vllm_omni.clients`; `protocol/duplex/**` MAY depend on `protocol/realtime/**`
|
||||
but never the reverse, and a Tier 1 class MUST NOT carry a vLLM-Omni extension
|
||||
field; and each protocol/codec behavior MUST have exactly one
|
||||
implementation that every consumer uses. A wire type MUST NOT carry a
|
||||
consumer's internal representation: the duplex mailbox rendering
|
||||
(`DuplexCommand.payload()`, whose channel differs from the client event for
|
||||
`session.update` and the `conversation.item.*` commands) stays engine-side.
|
||||
A duplex consumer (`engine/duplex/**`, `entrypoints/duplex/**`, the duplex
|
||||
clients) MUST import `vllm_omni.protocol.duplex` and MUST NOT import
|
||||
`vllm_omni.protocol.realtime` directly, so the tier boundary has exactly one
|
||||
extension point.
|
||||
|
||||
### ENTRY-INV-100: Public requests are normalized once
|
||||
|
||||
**Rule:** Public protocol values MUST be validated and converted to internal
|
||||
@@ -141,6 +191,9 @@ stage so `wake_up(stage_ids=[0])` does not skip a later `wake_up(stage_ids=[1])`
|
||||
Streaming input pumps take an admission slot immediately before each EngineCore
|
||||
ADD or update, not while waiting for the next client chunk. Frontend abort
|
||||
keeps `request_states` until `generate()` consumes the terminal output.
|
||||
Multi-API serving must preserve one client rank and one channel set per
|
||||
frontend, include every frontend in readiness/failure observation, and keep
|
||||
stage lifecycle ownership in the parent composition root.
|
||||
|
||||
## Promotion gate
|
||||
|
||||
|
||||
@@ -77,6 +77,22 @@ readiness, one pool per logical stage, replica identity and membership,
|
||||
selection and affinity, stage-client/process lifecycle, liveness, draining,
|
||||
and shutdown.
|
||||
|
||||
Local EngineCore initialization and multi-API serving use
|
||||
`StageRuntime.launch_stage_engines(num_api_servers=1)` as their common launch
|
||||
entry. It resolves plans when not supplied by the runtime's device-group
|
||||
scheduler, coordinates device locks and environment overlays, starts engines,
|
||||
and rolls back resources on startup or attachment failure. Single-API clients
|
||||
attach locally after readiness and take ownership of the engine resources;
|
||||
there is no additional API subprocess. Diffusion and remote attachment retain
|
||||
their backend-specific initialization.
|
||||
|
||||
For local multi-API serving, the parent runtime owns and launches each stage
|
||||
engine once. It supplies a distinct input/output channel for every API
|
||||
frontend and stage replica; frontend workers attach clients to those engines
|
||||
without acquiring stage-process ownership. Readiness observation and shutdown
|
||||
remain parent-owned. This topology currently excludes diffusion and
|
||||
remote/headless stages, intra-stage data parallelism, and Ray backends.
|
||||
|
||||
It does not own cross-stage request policy, model scheduler policy, connector
|
||||
transfer semantics, configuration precedence, or public error rendering.
|
||||
|
||||
@@ -114,6 +130,10 @@ affinity, membership changes, abort routing, process failure, draining, and
|
||||
repeated shutdown. Cross-stage policy changes belong in
|
||||
`engine_orchestration.md`.
|
||||
|
||||
For multi-API changes, additionally exercise per-frontend channel assignment,
|
||||
shared-engine startup handshakes, frontend failure observation, and parent
|
||||
cleanup without duplicate stage shutdown.
|
||||
|
||||
## Promotion gate
|
||||
|
||||
- Reconcile terminology and ownership after #5441 reaches a final state.
|
||||
|
||||
Reference in new issue
Block a user