[Bugfix] Fix Qwen3-TTS Stage 0 prefix-caching correctness (#3665)
Signed-off-by: Yueqian Lin <linyueqian@outlook.com> Signed-off-by: Rein Yang <ruiruyang2@gmail.com> Co-authored-by: Rein Yang <ruiruyang2@gmail.com> the ci failure case is irrelavent to this PR and occurs rarely
This commit is contained in:
1 parent
5f9aee193e
commit
e996723bc9
12 files changed
+655
-43
No files matched your search
@@ -13,7 +13,7 @@ DEFAULT_SHAPE = torch.Size([NUM_BLOCKS, BLOCK_SIZE, HIDDEN_SIZE])
|
||||
|
||||
|
||||
class MockInputBatch:
|
||||
def __init__(self, num_computed_tokens_cpu):
|
||||
def __init__(self, num_computed_tokens_cpu, block_table=None):
|
||||
self.req_ids = ["req1", "req2"]
|
||||
self.req_id_to_index = {req_id: i for i, req_id in enumerate(self.req_ids)}
|
||||
self.num_computed_tokens_cpu = num_computed_tokens_cpu
|
||||
@@ -21,11 +21,33 @@ class MockInputBatch:
|
||||
# Block table is only mocked for validation of length;
|
||||
# we don't actually need to add valid values here since
|
||||
# we patch the table when testing.
|
||||
class _DummyBlockTable:
|
||||
pass
|
||||
if block_table is None:
|
||||
|
||||
self.block_table = _DummyBlockTable()
|
||||
self.block_table.block_tables = [None]
|
||||
class _DummyBlockTable:
|
||||
pass
|
||||
|
||||
self.block_table = _DummyBlockTable()
|
||||
self.block_table.block_tables = [None]
|
||||
else:
|
||||
|
||||
class _TensorWrapper:
|
||||
def __init__(self, tensor):
|
||||
self.cpu = tensor
|
||||
|
||||
class _BlockGroup:
|
||||
def __init__(self, tensor):
|
||||
self.block_table = _TensorWrapper(tensor)
|
||||
|
||||
class _BlockTable:
|
||||
def __init__(self, tensor):
|
||||
self._group = _BlockGroup(tensor)
|
||||
self.block_tables = [self._group.block_table]
|
||||
|
||||
def __getitem__(self, idx):
|
||||
assert idx == 0
|
||||
return self._group
|
||||
|
||||
self.block_table = _BlockTable(block_table)
|
||||
|
||||
|
||||
def get_omni_pcache_with_mm_tensors(feat_dims, seq_len) -> OmniTensorPrefixCache:
|
||||
@@ -171,6 +193,94 @@ def test_update_with_multimodal_outputs(feat_dims):
|
||||
assert torch.all(slot_states == new_output)
|
||||
|
||||
|
||||
def test_deferred_multimodal_cache_write_commits_on_completion():
|
||||
cache = get_omni_pcache()
|
||||
mm_key = "codes.audio"
|
||||
block_table = torch.tensor(
|
||||
[
|
||||
[2, 3, 4, 5],
|
||||
[6, 7, 8, 9],
|
||||
],
|
||||
dtype=torch.long,
|
||||
)
|
||||
input_batch = MockInputBatch(
|
||||
num_computed_tokens_cpu=torch.tensor([4, 0], dtype=torch.long),
|
||||
block_table=block_table,
|
||||
)
|
||||
|
||||
first_chunk = torch.arange(8, dtype=torch.long).reshape(4, 2)
|
||||
cache.update_omni_tensor_prefix_cache(
|
||||
hidden_states=None,
|
||||
multimodal_outputs={mm_key: first_chunk},
|
||||
num_tokens_unpadded=4,
|
||||
slot_mapping=torch.arange(8, 12),
|
||||
skip_mm_cache_keys={mm_key},
|
||||
)
|
||||
|
||||
rows = cache.mm_outputs_cache[mm_key].view(-1, 2)
|
||||
assert torch.all(rows[8:12] == 0)
|
||||
|
||||
cache.stage_deferred_mm_outputs(
|
||||
query_start_loc=torch.tensor([0, 4], dtype=torch.long),
|
||||
input_batch=input_batch,
|
||||
multimodal_outputs={mm_key: first_chunk},
|
||||
num_scheduled_tokens={"req1": 4, "req2": 0},
|
||||
deferred_mm_cache_keys={mm_key},
|
||||
)
|
||||
|
||||
second_chunk = torch.arange(8, 16, dtype=torch.long).reshape(4, 2)
|
||||
input_batch.num_computed_tokens_cpu = torch.tensor([8, 0], dtype=torch.long)
|
||||
cache.stage_deferred_mm_outputs(
|
||||
query_start_loc=torch.tensor([0, 4], dtype=torch.long),
|
||||
input_batch=input_batch,
|
||||
multimodal_outputs={mm_key: second_chunk},
|
||||
num_scheduled_tokens={"req1": 4, "req2": 0},
|
||||
deferred_mm_cache_keys={mm_key},
|
||||
)
|
||||
|
||||
cache.commit_deferred_mm_outputs({"req1"}, input_batch)
|
||||
|
||||
expected = torch.cat([first_chunk, second_chunk], dim=0)
|
||||
rows = cache.mm_outputs_cache[mm_key].view(-1, 2)
|
||||
assert torch.equal(rows[8:16], expected)
|
||||
|
||||
|
||||
def test_deferred_multimodal_cache_can_be_merged_on_full_block_hit():
|
||||
cache = get_omni_pcache()
|
||||
mm_key = "codes.audio"
|
||||
block_table = torch.tensor(
|
||||
[
|
||||
[2, 3, 4, 5],
|
||||
[6, 7, 8, 9],
|
||||
],
|
||||
dtype=torch.long,
|
||||
)
|
||||
input_batch = MockInputBatch(
|
||||
num_computed_tokens_cpu=torch.tensor([8, 0], dtype=torch.long),
|
||||
block_table=block_table,
|
||||
)
|
||||
cached_codes = torch.arange(16, dtype=torch.long).reshape(8, 2)
|
||||
cache.stage_deferred_mm_outputs(
|
||||
query_start_loc=torch.tensor([0, 8], dtype=torch.long),
|
||||
input_batch=input_batch,
|
||||
multimodal_outputs={mm_key: cached_codes},
|
||||
num_scheduled_tokens={"req1": 8, "req2": 0},
|
||||
deferred_mm_cache_keys={mm_key},
|
||||
)
|
||||
cache.commit_deferred_mm_outputs({"req1"}, input_batch)
|
||||
|
||||
new_codes = torch.tensor([[99, 100]], dtype=torch.long)
|
||||
cache.add_prefix_cached_new_req_id("req1")
|
||||
merged = cache.get_merged_multimodal_states(
|
||||
query_start_loc=torch.tensor([0, 1], dtype=torch.long),
|
||||
input_batch=input_batch,
|
||||
multimodal_outputs={mm_key: new_codes},
|
||||
num_scheduled_tokens={"req1": 1, "req2": 0},
|
||||
)
|
||||
|
||||
assert torch.equal(merged[mm_key]["req1"], torch.cat([cached_codes, new_codes], dim=0))
|
||||
|
||||
|
||||
### Tests for Merging
|
||||
def fake_get_cached_block_ids(self, req_idx, *args, **kwargs):
|
||||
"""Fake block table lookup.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from collections import OrderedDict
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
@@ -15,6 +16,8 @@ from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import (
|
||||
def _make_minimal_talker():
|
||||
model = Qwen3TTSTalkerForConditionalGeneration.__new__(Qwen3TTSTalkerForConditionalGeneration)
|
||||
model.talker_config = SimpleNamespace(codec_pad_id=7, num_code_groups=16)
|
||||
model._ref_audio_artifact_cache_max_entries = 256
|
||||
model._ref_audio_artifact_cache = OrderedDict()
|
||||
return model
|
||||
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ class OmniTensorPrefixCache:
|
||||
self.mm_outputs_cache = {}
|
||||
self.mm_cache_keys = set()
|
||||
self._new_req_cache_hit_ids: set[str] = set()
|
||||
self._deferred_mm_outputs: dict[str, dict[str, list[tuple[int, torch.Tensor]]]] = {}
|
||||
|
||||
def maybe_init_missing_mm_cache_keys(self, multimodal_outputs: dict, seq_len: int):
|
||||
"""Given multimodal outputs from executing the model, dynamically
|
||||
@@ -62,7 +63,17 @@ class OmniTensorPrefixCache:
|
||||
determined by the warmup.
|
||||
"""
|
||||
for key, val in multimodal_outputs.items():
|
||||
if isinstance(val, torch.Tensor) and val.shape[0] == seq_len and key not in self.mm_cache_keys:
|
||||
# Only cache per-token feature tensors: 2D+ with first dim == seq_len.
|
||||
# A 1D tensor of shape (seq_len,) is a broadcast scalar (per-request
|
||||
# metadata such as ref_code_len / codec_streaming), not per-token data;
|
||||
# caching it by slot causes a shape mismatch when a later request has a
|
||||
# different scheduled seq length.
|
||||
if (
|
||||
isinstance(val, torch.Tensor)
|
||||
and val.ndim >= 2
|
||||
and val.shape[0] == seq_len
|
||||
and key not in self.mm_cache_keys
|
||||
):
|
||||
feat_dim = val.shape[-1]
|
||||
self.mm_outputs_cache[key] = self._get_cache_tensor(
|
||||
dtype=val.dtype,
|
||||
@@ -89,6 +100,10 @@ class OmniTensorPrefixCache:
|
||||
"""Clears the cache hit IDs to prepare for a new engine step."""
|
||||
self._new_req_cache_hit_ids.clear()
|
||||
|
||||
def has_prefix_cached_new_req_ids(self) -> bool:
|
||||
"""Return True when this step contains a newly scheduled prefix hit."""
|
||||
return bool(self._new_req_cache_hit_ids)
|
||||
|
||||
@staticmethod
|
||||
def _coerce_to_cpu_tensor(maybe_gpu_tensor: torch.Tensor) -> torch.Tensor:
|
||||
"""Convert GPU tensors -> contiguous CPU tensors if needed."""
|
||||
@@ -101,6 +116,7 @@ class OmniTensorPrefixCache:
|
||||
num_tokens_unpadded: int,
|
||||
slot_mapping: torch.Tensor,
|
||||
num_tokens_padded: int | None = None,
|
||||
skip_mm_cache_keys: set[str] | None = None,
|
||||
):
|
||||
"""Updates the hidden cache state for the provided hidden states and multimodal outputs.
|
||||
|
||||
@@ -110,10 +126,12 @@ class OmniTensorPrefixCache:
|
||||
num_tokens_unpadded: Number of tokens without padding
|
||||
slot_mapping: Slot mapping for the input sequence
|
||||
num_tokens_padded: Total number of tokens including padding
|
||||
skip_mm_cache_keys: Multimodal keys whose CPU cache writes are deferred
|
||||
"""
|
||||
unpadded_slot_mapping = slot_mapping[:num_tokens_unpadded]
|
||||
if num_tokens_padded is None:
|
||||
num_tokens_padded = num_tokens_unpadded
|
||||
skip_mm_cache_keys = skip_mm_cache_keys or set()
|
||||
|
||||
if hidden_states is not None:
|
||||
# Slice to unpadded portion before caching
|
||||
@@ -136,6 +154,8 @@ class OmniTensorPrefixCache:
|
||||
|
||||
for mm_out_key, mm_cache in self.mm_outputs_cache.items():
|
||||
if mm_out_key in multimodal_outputs:
|
||||
if mm_out_key in skip_mm_cache_keys:
|
||||
continue
|
||||
# Slice to unpadded portion before caching
|
||||
mm_state = multimodal_outputs[mm_out_key][:num_tokens_unpadded]
|
||||
mm_state = OmniTensorPrefixCache._coerce_to_cpu_tensor(mm_state)
|
||||
@@ -143,6 +163,165 @@ class OmniTensorPrefixCache:
|
||||
flat_cache[unpadded_slot_mapping] = mm_state
|
||||
logger.debug("Writing to mm output cache for %s tokens", num_tokens_unpadded)
|
||||
|
||||
def stage_deferred_mm_outputs(
|
||||
self,
|
||||
query_start_loc: torch.Tensor,
|
||||
input_batch: InputBatch,
|
||||
multimodal_outputs: dict[str, torch.Tensor] | None,
|
||||
num_scheduled_tokens: dict[str, int],
|
||||
deferred_mm_cache_keys: set[str],
|
||||
) -> None:
|
||||
"""Keep GPU multimodal chunks until a request finishes.
|
||||
|
||||
The normal prefix-cache write path copies every cached multimodal tensor
|
||||
to CPU on every step. For model outputs that are only needed by future
|
||||
full-block prefix hits, we can keep detached GPU chunks and materialize
|
||||
the CPU cache once when the request completes.
|
||||
"""
|
||||
if not multimodal_outputs or not deferred_mm_cache_keys:
|
||||
return
|
||||
|
||||
for mm_key in deferred_mm_cache_keys:
|
||||
mm_state = multimodal_outputs.get(mm_key)
|
||||
if not isinstance(mm_state, torch.Tensor) or mm_state.ndim < 2:
|
||||
continue
|
||||
for req_id in input_batch.req_ids:
|
||||
req_idx = input_batch.req_id_to_index[req_id]
|
||||
sched = int(num_scheduled_tokens.get(req_id, 0))
|
||||
if sched <= 0:
|
||||
continue
|
||||
start = int(query_start_loc[req_idx])
|
||||
end = start + sched
|
||||
if start >= int(mm_state.shape[0]):
|
||||
continue
|
||||
end = min(end, int(mm_state.shape[0]))
|
||||
if end <= start:
|
||||
continue
|
||||
|
||||
computed_end = int(input_batch.num_computed_tokens_cpu[req_idx])
|
||||
token_start = max(0, computed_end - sched)
|
||||
chunk = mm_state[start:end].detach()
|
||||
self._deferred_mm_outputs.setdefault(req_id, {}).setdefault(mm_key, []).append((token_start, chunk))
|
||||
|
||||
def commit_deferred_mm_outputs(
|
||||
self,
|
||||
finished_req_ids: set[str] | list[str],
|
||||
input_batch: InputBatch,
|
||||
) -> None:
|
||||
"""Write deferred multimodal chunks into the CPU prefix cache.
|
||||
|
||||
This must run before finished requests are removed from ``input_batch``,
|
||||
because the block table is needed to map logical token positions to KV
|
||||
cache slots.
|
||||
"""
|
||||
if not finished_req_ids or not self._deferred_mm_outputs:
|
||||
return
|
||||
|
||||
for req_id in tuple(finished_req_ids):
|
||||
per_key = self._deferred_mm_outputs.pop(req_id, None)
|
||||
if not per_key:
|
||||
continue
|
||||
req_idx = input_batch.req_id_to_index.get(req_id)
|
||||
if req_idx is None:
|
||||
continue
|
||||
for mm_key, chunks in per_key.items():
|
||||
self._commit_deferred_mm_key(req_idx, input_batch, mm_key, chunks)
|
||||
|
||||
def discard_deferred_mm_outputs(self, req_id: str) -> None:
|
||||
"""Drop deferred chunks for requests that leave without a cache commit."""
|
||||
self._deferred_mm_outputs.pop(req_id, None)
|
||||
|
||||
def _commit_deferred_mm_key(
|
||||
self,
|
||||
req_idx: int,
|
||||
input_batch: InputBatch,
|
||||
mm_key: str,
|
||||
chunks: list[tuple[int, torch.Tensor]],
|
||||
) -> None:
|
||||
if not chunks:
|
||||
return
|
||||
|
||||
chunks = sorted(chunks, key=lambda item: item[0])
|
||||
first_start = chunks[0][0]
|
||||
expected_start = first_start
|
||||
tensors: list[torch.Tensor] = []
|
||||
for token_start, tensor in chunks:
|
||||
if tensor.numel() == 0:
|
||||
continue
|
||||
if token_start != expected_start:
|
||||
if tensors:
|
||||
self._write_deferred_mm_tensor(req_idx, input_batch, mm_key, tensors, first_start)
|
||||
first_start = token_start
|
||||
expected_start = token_start
|
||||
tensors = []
|
||||
tensors.append(tensor)
|
||||
expected_start += int(tensor.shape[0])
|
||||
|
||||
if tensors:
|
||||
self._write_deferred_mm_tensor(req_idx, input_batch, mm_key, tensors, first_start)
|
||||
|
||||
def _write_deferred_mm_tensor(
|
||||
self,
|
||||
req_idx: int,
|
||||
input_batch: InputBatch,
|
||||
mm_key: str,
|
||||
tensors: list[torch.Tensor],
|
||||
token_start: int,
|
||||
) -> None:
|
||||
mm_state = tensors[0] if len(tensors) == 1 else torch.cat(tensors, dim=0)
|
||||
if mm_state.ndim < 2 or mm_state.shape[0] == 0:
|
||||
return
|
||||
|
||||
if mm_key not in self.mm_cache_keys:
|
||||
self.mm_outputs_cache[mm_key] = self._get_cache_tensor(
|
||||
dtype=mm_state.dtype,
|
||||
hidden_size=int(mm_state.shape[-1]),
|
||||
)
|
||||
self.mm_cache_keys.add(mm_key)
|
||||
logger.info(
|
||||
"Initializing deferred multimodal output cache of size %s for key: %s",
|
||||
list(self.mm_outputs_cache[mm_key].shape),
|
||||
mm_key,
|
||||
)
|
||||
|
||||
slots = self._get_slot_ids_for_token_range(
|
||||
req_idx=req_idx,
|
||||
input_batch=input_batch,
|
||||
token_start=token_start,
|
||||
num_tokens=int(mm_state.shape[0]),
|
||||
)
|
||||
if slots.numel() == 0:
|
||||
return
|
||||
|
||||
mm_state_cpu = OmniTensorPrefixCache._coerce_to_cpu_tensor(mm_state[: slots.numel()])
|
||||
mm_cache = self.mm_outputs_cache[mm_key]
|
||||
flat_cache = mm_cache.view(-1, mm_cache.shape[-1])
|
||||
flat_cache[slots] = mm_state_cpu
|
||||
|
||||
def _get_slot_ids_for_token_range(
|
||||
self,
|
||||
req_idx: int,
|
||||
input_batch: InputBatch,
|
||||
token_start: int,
|
||||
num_tokens: int,
|
||||
) -> torch.Tensor:
|
||||
if num_tokens <= 0:
|
||||
return torch.empty((0,), dtype=torch.long)
|
||||
|
||||
block_table = input_batch.block_table[0].block_table.cpu
|
||||
token_positions = torch.arange(token_start, token_start + num_tokens, dtype=torch.long)
|
||||
block_offsets = token_positions // self.block_size
|
||||
max_blocks = int(block_table.shape[1])
|
||||
valid = block_offsets < max_blocks
|
||||
if not bool(valid.all()):
|
||||
token_positions = token_positions[valid]
|
||||
block_offsets = block_offsets[valid]
|
||||
if token_positions.numel() == 0:
|
||||
return torch.empty((0,), dtype=torch.long)
|
||||
|
||||
block_ids = block_table[req_idx, block_offsets].to(torch.long)
|
||||
return block_ids * self.block_size + (token_positions % self.block_size)
|
||||
|
||||
def _coerce_to_payload_dict(
|
||||
self,
|
||||
element: object,
|
||||
@@ -151,17 +330,25 @@ class OmniTensorPrefixCache:
|
||||
num_scheduled_tokens: dict[str, int],
|
||||
) -> dict[str, object]:
|
||||
"""Build the multimodal passthrough data per request for
|
||||
the object under consideration. This is identical to the case
|
||||
for no prefix cache when we tensor does have a first dimension
|
||||
matching the seq len.
|
||||
the object under consideration. This mirrors the no-prefix-cache
|
||||
path: a tensor whose first dimension matches the total scheduled
|
||||
token count is sliced per request, so 1D (seq_len,) metadata that
|
||||
is intentionally not cached (e.g. ref_code_len, codec_streaming)
|
||||
is still split per request instead of leaking the whole batch.
|
||||
"""
|
||||
total_scheduled_tokens = sum(int(num_scheduled_tokens[r]) for r in input_batch.req_ids)
|
||||
elem_dict = {}
|
||||
for req_id in input_batch.req_ids:
|
||||
req_idx = input_batch.req_id_to_index[req_id]
|
||||
start = query_start_loc[req_idx]
|
||||
end = start + num_scheduled_tokens[req_id]
|
||||
elem_dict[req_id] = to_payload_element(
|
||||
element, req_idx, start=start, end=end, pass_lists_through=True, seq_len=None
|
||||
element,
|
||||
req_idx,
|
||||
start=start,
|
||||
end=end,
|
||||
pass_lists_through=True,
|
||||
seq_len=total_scheduled_tokens,
|
||||
)
|
||||
return elem_dict
|
||||
|
||||
|
||||
@@ -34,7 +34,12 @@ stages:
|
||||
max_num_seqs: 10
|
||||
gpu_memory_utilization: 0.3
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
# Prefix caching on by default. The HS opt-out (talker only consumes the
|
||||
# last token's hidden state) and deferred mm-output writes added in this
|
||||
# PR make per-step bookkeeping negligible, and any text repetition across
|
||||
# requests (same voice or shared ref_audio) is a net throughput win.
|
||||
# Disable explicitly if memory headroom is tight on a single-GPU deploy.
|
||||
enable_prefix_caching: true
|
||||
async_scheduling: true
|
||||
max_num_batched_tokens: 512
|
||||
max_model_len: 4096
|
||||
|
||||
@@ -107,7 +107,7 @@ def build_engine_core_request_from_tokens(
|
||||
pooling_params=pooling_params,
|
||||
arrival_time=arrival_time,
|
||||
lora_request=getattr(params, "lora_request", None),
|
||||
cache_salt=None,
|
||||
cache_salt=prompt.get("cache_salt"),
|
||||
data_parallel_rank=None,
|
||||
prompt_embeds=prompt_embeds,
|
||||
resumable=resumable,
|
||||
|
||||
@@ -277,6 +277,10 @@ class OmniBase(PDDisaggregationMixin):
|
||||
try:
|
||||
if req_state is None or req_state.metrics is None:
|
||||
return
|
||||
if self.log_stats:
|
||||
# Emit per-request orchestrator timing (including e2e_total_ms)
|
||||
# before dropping request state.
|
||||
req_state.metrics.build_and_log_summary()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"[%s] Failed to build/log summary for req=%s",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
@@ -186,6 +187,55 @@ def _validate_path_within_directory(file_path: Path, directory: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _conditioning_cache_salt(request, tts_params: dict | None = None) -> str:
|
||||
"""Stable hash of the real Stage 0 conditioning for the prefix cache.
|
||||
|
||||
The talker's vLLM prompt is placeholder token ids; the real inputs are
|
||||
rebuilt from text / ref_audio / ref_text into inputs_embeds. vLLM hashes
|
||||
token ids (folded with cache_salt) for prefix caching, so without a salt
|
||||
every request collides and a hit could reuse KV from a semantically
|
||||
different input. Tying the salt to the conditioning keeps a hit safe:
|
||||
identical conditioning may share the prefix, any difference never does.
|
||||
|
||||
Raw request fields alone are not enough for uploaded voices: the request
|
||||
only carries the voice *name* (ref_audio/ref_text/task_type are resolved
|
||||
from stored voice data into ``tts_params``). Delete + re-upload under the
|
||||
same name leaves every raw field identical, so the resolved conditioning
|
||||
must also be folded in. ``voice_created_at`` bumps on every (re-)upload,
|
||||
which uniquely identifies the resolved reference artifact together with
|
||||
the voice name; the decoded ref_audio array itself need not be hashed.
|
||||
"""
|
||||
h = hashlib.sha256()
|
||||
for part in (
|
||||
request.input,
|
||||
request.task_type,
|
||||
request.language,
|
||||
request.voice,
|
||||
request.ref_text,
|
||||
request.ref_audio,
|
||||
request.instructions,
|
||||
request.x_vector_only_mode,
|
||||
request.speaker_embedding,
|
||||
):
|
||||
h.update(b"\x00")
|
||||
if part is not None:
|
||||
h.update(repr(part).encode("utf-8"))
|
||||
# Fold resolved conditioning that is auto-derived for uploaded voices and
|
||||
# absent from the raw request.
|
||||
for key in (
|
||||
"voice_created_at",
|
||||
"task_type",
|
||||
"speaker",
|
||||
"ref_text",
|
||||
"x_vector_only_mode",
|
||||
):
|
||||
h.update(b"\x00")
|
||||
value = tts_params.get(key) if tts_params is not None else None
|
||||
if value is not None:
|
||||
h.update(repr(value).encode("utf-8"))
|
||||
return h.hexdigest()[:32]
|
||||
|
||||
|
||||
class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
_diffusion_mode: bool = False
|
||||
_tts_executor: ThreadPoolExecutor | None = None
|
||||
@@ -1500,6 +1550,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
request_id: str,
|
||||
response_format: str = "pcm",
|
||||
raw_request: Request | None = None,
|
||||
request_start_s: float | None = None,
|
||||
):
|
||||
"""Generate audio chunks for streaming response.
|
||||
|
||||
@@ -1520,6 +1571,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
prev_count = 0
|
||||
sample_rate_val = 24000
|
||||
first_chunk = True
|
||||
first_audio_chunk_s: float | None = None
|
||||
stream_start_s = request_start_s if request_start_s is not None else time.perf_counter()
|
||||
|
||||
try:
|
||||
async for res in generator:
|
||||
@@ -1571,11 +1624,40 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
stream_format="audio",
|
||||
base64_encode=False,
|
||||
)
|
||||
if first_audio_chunk_s is None:
|
||||
first_audio_chunk_s = time.perf_counter()
|
||||
yield self.create_audio(audio_obj).audio_data
|
||||
total_ms = (time.perf_counter() - stream_start_s) * 1000.0
|
||||
if first_audio_chunk_s is not None:
|
||||
first_chunk_ms = (first_audio_chunk_s - stream_start_s) * 1000.0
|
||||
logger.info(
|
||||
"[SpeechE2E] request_id=%s stream=true status=ok total_ms=%.2f first_chunk_ms=%.2f",
|
||||
request_id,
|
||||
total_ms,
|
||||
first_chunk_ms,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"[SpeechE2E] request_id=%s stream=true status=ok total_ms=%.2f first_chunk_ms=NA",
|
||||
request_id,
|
||||
total_ms,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
total_ms = (time.perf_counter() - stream_start_s) * 1000.0
|
||||
logger.info(
|
||||
"[SpeechE2E] request_id=%s stream=true status=cancelled total_ms=%.2f",
|
||||
request_id,
|
||||
total_ms,
|
||||
)
|
||||
logger.info("Streaming request %s cancelled by client", request_id)
|
||||
raise
|
||||
except EngineDeadError as e:
|
||||
total_ms = (time.perf_counter() - stream_start_s) * 1000.0
|
||||
logger.error(
|
||||
"[SpeechE2E] request_id=%s stream=true status=engine_dead total_ms=%.2f",
|
||||
request_id,
|
||||
total_ms,
|
||||
)
|
||||
logger.error(
|
||||
"EngineDeadError during streaming speech for %s: %s",
|
||||
request_id,
|
||||
@@ -1589,6 +1671,13 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
total_ms = (time.perf_counter() - stream_start_s) * 1000.0
|
||||
logger.exception(
|
||||
"[SpeechE2E] request_id=%s stream=true status=error total_ms=%.2f error=%s",
|
||||
request_id,
|
||||
total_ms,
|
||||
e,
|
||||
)
|
||||
logger.exception("Streaming speech generation failed for %s: %s", request_id, e)
|
||||
raise
|
||||
|
||||
@@ -2195,6 +2284,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
tts_params["seed"] = [sampling_params_list[0].seed]
|
||||
prompt = tokens_input(prompt_token_ids=[1])
|
||||
prompt["additional_information"] = tts_params
|
||||
prompt["cache_salt"] = _conditioning_cache_salt(request, tts_params)
|
||||
else:
|
||||
tts_params = self._build_tts_params(request)
|
||||
# Resolve ref_audio (explicit or auto-set for uploaded voices)
|
||||
@@ -2210,6 +2300,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
ph_len = await self._estimate_prompt_len_async(tts_params)
|
||||
prompt = tokens_input(prompt_token_ids=[1] * ph_len)
|
||||
prompt["additional_information"] = tts_params
|
||||
prompt["cache_salt"] = _conditioning_cache_salt(request, tts_params)
|
||||
else:
|
||||
# Qwen omni models (Qwen3-Omni, Qwen2.5-Omni) use a "talker"
|
||||
# stage whose preprocess requires chat-templated tokens. The
|
||||
@@ -2651,6 +2742,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
return error_check_ret
|
||||
|
||||
request_id = f"speech-{random_uuid()}"
|
||||
request_start_s = time.perf_counter()
|
||||
if raw_request:
|
||||
raw_request.state.request_metadata = RequestResponseMetadata(
|
||||
request_id=request_id,
|
||||
@@ -2683,20 +2775,58 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
request_id,
|
||||
response_format,
|
||||
raw_request=raw_request,
|
||||
request_start_s=request_start_s,
|
||||
),
|
||||
media_type=media_type,
|
||||
)
|
||||
|
||||
audio_bytes, media_type = await self._generate_audio_bytes(request, request_id=request_id)
|
||||
total_ms = (time.perf_counter() - request_start_s) * 1000.0
|
||||
logger.info(
|
||||
"[SpeechE2E] request_id=%s stream=false status=ok total_ms=%.2f response_bytes=%d",
|
||||
request_id,
|
||||
total_ms,
|
||||
len(audio_bytes) if isinstance(audio_bytes, (bytes, bytearray)) else len(str(audio_bytes)),
|
||||
)
|
||||
return Response(content=audio_bytes, media_type=media_type)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
total_ms = (time.perf_counter() - request_start_s) * 1000.0
|
||||
logger.info(
|
||||
"[SpeechE2E] request_id=%s stream=%s status=cancelled total_ms=%.2f",
|
||||
request_id,
|
||||
bool(request.stream),
|
||||
total_ms,
|
||||
)
|
||||
return self.create_error_response("Client disconnected")
|
||||
except (EngineGenerateError, EngineDeadError):
|
||||
total_ms = (time.perf_counter() - request_start_s) * 1000.0
|
||||
logger.error(
|
||||
"[SpeechE2E] request_id=%s stream=%s status=engine_error total_ms=%.2f",
|
||||
request_id,
|
||||
bool(request.stream),
|
||||
total_ms,
|
||||
)
|
||||
raise # Propagate to the global Omni exception handler
|
||||
except ValueError as e:
|
||||
total_ms = (time.perf_counter() - request_start_s) * 1000.0
|
||||
logger.warning(
|
||||
"[SpeechE2E] request_id=%s stream=%s status=bad_request total_ms=%.2f error=%s",
|
||||
request_id,
|
||||
bool(request.stream),
|
||||
total_ms,
|
||||
e,
|
||||
)
|
||||
return self.create_error_response(e)
|
||||
except Exception as e:
|
||||
total_ms = (time.perf_counter() - request_start_s) * 1000.0
|
||||
logger.exception(
|
||||
"[SpeechE2E] request_id=%s stream=%s status=error total_ms=%.2f error=%s",
|
||||
request_id,
|
||||
bool(request.stream),
|
||||
total_ms,
|
||||
e,
|
||||
)
|
||||
logger.exception("Speech generation failed: %s", e)
|
||||
return self.create_error_response(f"Speech generation failed: {e}")
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import copy
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
@@ -40,6 +42,7 @@ logger = init_logger(__name__)
|
||||
_TRAILING_TEXT_COMPACT_MIN_FRAMES = 64
|
||||
_PRECOMPUTED_REF_CODE_KEY = "precomputed_ref"
|
||||
_NORMALIZED_REF_AUDIO_KEY = "_qwen3_tts_normalized_ref_audio"
|
||||
_REF_AUDIO_CACHE_KEY = "_qwen3_tts_ref_audio_cache_key"
|
||||
_PRECOMPUTED_TEXT_IDS_KEY = "_qwen3_tts_text_ids"
|
||||
_PRECOMPUTED_REF_IDS_KEY = "_qwen3_tts_ref_ids"
|
||||
|
||||
@@ -354,6 +357,17 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
self.have_multimodal_outputs = True
|
||||
self.has_preprocess = True
|
||||
self.has_postprocess = True
|
||||
# Qwen3-TTS postprocess() only reads hidden_states[-1, :]. On a prefix-
|
||||
# cache hit, the last hidden state is in the newly computed tail, so
|
||||
# reconstructing the full cached_prefix + new_tail span is wasted work.
|
||||
# Opt out of the per-step GPU->CPU hidden-state cache write and merged-
|
||||
# tensor read; postprocess receives the tail-only slice instead, which
|
||||
# avoids ~18 ms merge + ~6 ms write per step (Sy0307 profile, #3665).
|
||||
self.requires_full_prefix_cached_hidden_states = False
|
||||
# ``codes.audio`` is only needed for future prefix-hit reconstruction
|
||||
# after a request has produced codec rows. Keep per-step rows on GPU and
|
||||
# materialize the CPU OmniTensorPrefixCache entry once at completion.
|
||||
self.deferred_prefix_cache_mm_keys = {"codes.audio"}
|
||||
# Used by OmniGPUModelRunner for the GPU-side MTP fast-path.
|
||||
self.mtp_hidden_size = int(self.talker_config.hidden_size)
|
||||
# OmniGPUModelRunner will store talker_mtp output under this key in
|
||||
@@ -443,6 +457,8 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
self._speech_tokenizer: Qwen3TTSTokenizer | None = None
|
||||
|
||||
self._speaker_cache = get_speaker_cache()
|
||||
self._ref_audio_artifact_cache_max_entries = 256
|
||||
self._ref_audio_artifact_cache: OrderedDict[str, dict[str, torch.Tensor | bool]] = OrderedDict()
|
||||
raw_subtalker_sampling = getattr(vllm_config.model_config, "subtalker_sampling_params", None)
|
||||
self._subtalker_sampling_params: dict[str, Any] = (
|
||||
dict(raw_subtalker_sampling) if isinstance(raw_subtalker_sampling, Mapping) else {}
|
||||
@@ -634,9 +650,11 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
info_update["meta"]["ref_code_len"] = int(ref_code_len)
|
||||
# Always return a span_len slice; if the scheduled placeholder is longer, pad with tts_pad_embed.
|
||||
# This preserves placeholder/embedding alignment.
|
||||
offset = 0
|
||||
s = 0
|
||||
e = span_len
|
||||
offset = int(info_dict.get("_omni_num_computed_tokens", 0) or 0)
|
||||
if offset < 0:
|
||||
offset = 0
|
||||
s = max(0, min(offset, int(prompt_embeds_cpu.shape[0])))
|
||||
e = max(0, min(offset + span_len, int(prompt_embeds_cpu.shape[0])))
|
||||
take = prompt_embeds_cpu[s:e]
|
||||
if int(take.shape[0]) < span_len:
|
||||
pad_n = int(span_len - int(take.shape[0]))
|
||||
@@ -1393,9 +1411,50 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
# 12Hz: likely [T, Q] or [B, T, Q]
|
||||
if ref_code.ndim == 3:
|
||||
ref_code = ref_code[0]
|
||||
return ref_code.to(device=next(self.parameters()).device, dtype=torch.long)
|
||||
out = ref_code.to(device=next(self.parameters()).device, dtype=torch.long)
|
||||
return out
|
||||
raise ValueError("SpeechTokenizer.encode did not return audio_codes tensor")
|
||||
|
||||
@staticmethod
|
||||
def _make_ref_audio_cache_key(wav: np.ndarray, sr: int) -> str:
|
||||
wav_f32 = wav.astype(np.float32, copy=False).reshape(-1)
|
||||
h = hashlib.sha1()
|
||||
h.update(int(sr).to_bytes(4, byteorder="little", signed=False))
|
||||
h.update(int(wav_f32.size).to_bytes(8, byteorder="little", signed=False))
|
||||
h.update(wav_f32.tobytes(order="C"))
|
||||
return h.hexdigest()
|
||||
|
||||
def _get_ref_audio_artifacts(self, cache_key: str) -> dict[str, torch.Tensor | bool] | None:
|
||||
entry = self._ref_audio_artifact_cache.get(cache_key)
|
||||
if entry is None:
|
||||
return None
|
||||
# Touch for LRU.
|
||||
self._ref_audio_artifact_cache.move_to_end(cache_key, last=True)
|
||||
return entry
|
||||
|
||||
def _put_ref_audio_artifacts(
|
||||
self,
|
||||
cache_key: str,
|
||||
*,
|
||||
ref_code: torch.Tensor | None = None,
|
||||
ref_spk_embedding: torch.Tensor | None = None,
|
||||
) -> None:
|
||||
if not cache_key:
|
||||
return
|
||||
entry = self._ref_audio_artifact_cache.get(cache_key)
|
||||
if entry is None:
|
||||
entry = {}
|
||||
if isinstance(ref_code, torch.Tensor):
|
||||
entry["ref_code"] = ref_code.detach().to("cpu", dtype=torch.long).contiguous()
|
||||
if isinstance(ref_spk_embedding, torch.Tensor):
|
||||
entry["ref_spk_embedding"] = ref_spk_embedding.detach().to("cpu", dtype=torch.bfloat16).reshape(-1)
|
||||
if not entry:
|
||||
return
|
||||
self._ref_audio_artifact_cache[cache_key] = entry
|
||||
self._ref_audio_artifact_cache.move_to_end(cache_key, last=True)
|
||||
while len(self._ref_audio_artifact_cache) > self._ref_audio_artifact_cache_max_entries:
|
||||
self._ref_audio_artifact_cache.popitem(last=False)
|
||||
|
||||
@staticmethod
|
||||
def _first_value(value: object, default: object = None) -> object:
|
||||
if isinstance(value, list):
|
||||
@@ -1544,6 +1603,14 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
# invalid ref_audio errors with its existing messages.
|
||||
continue
|
||||
info_dict[_NORMALIZED_REF_AUDIO_KEY] = (wav, sr)
|
||||
cache_key = self._make_ref_audio_cache_key(wav, sr)
|
||||
info_dict[_REF_AUDIO_CACHE_KEY] = cache_key
|
||||
cached = self._get_ref_audio_artifacts(cache_key)
|
||||
if cached is not None:
|
||||
cached_ref_code = self._coerce_ref_code_tensor(cached.get("ref_code"), device=device)
|
||||
if isinstance(cached_ref_code, torch.Tensor):
|
||||
info_dict.setdefault("codes", {})[_PRECOMPUTED_REF_CODE_KEY] = cached_ref_code
|
||||
continue
|
||||
groups.setdefault(int(sr), []).append((info_dict, wav, int(sr)))
|
||||
|
||||
if pending_text or pending_ref_text:
|
||||
@@ -1587,6 +1654,9 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
continue
|
||||
info_dict.setdefault("codes", {})[_PRECOMPUTED_REF_CODE_KEY] = ref_code_t
|
||||
info_dict[_NORMALIZED_REF_AUDIO_KEY] = (wav, item_sr)
|
||||
cache_key = info_dict.get(_REF_AUDIO_CACHE_KEY)
|
||||
if isinstance(cache_key, str) and cache_key:
|
||||
self._put_ref_audio_artifacts(cache_key, ref_code=ref_code_t)
|
||||
|
||||
def _generate_icl_prompt(
|
||||
self,
|
||||
@@ -1773,6 +1843,7 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
voice_clone_prompt = _normalize_voice_clone_prompt(info_dict.get("voice_clone_prompt"))
|
||||
ref_audio_wav: np.ndarray | None = None
|
||||
ref_audio_sr: int | None = None
|
||||
ref_audio_cache_key: str | None = None
|
||||
|
||||
def _get_ref_audio() -> tuple[np.ndarray, int]:
|
||||
nonlocal ref_audio_wav, ref_audio_sr
|
||||
@@ -1792,6 +1863,32 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
ref_audio_wav, ref_audio_sr = self._normalize_ref_audio(ref_audio_list[0])
|
||||
return ref_audio_wav, ref_audio_sr
|
||||
|
||||
def _get_ref_audio_cache_key() -> str:
|
||||
nonlocal ref_audio_cache_key
|
||||
if isinstance(ref_audio_cache_key, str) and ref_audio_cache_key:
|
||||
return ref_audio_cache_key
|
||||
cached_key = info_dict.get(_REF_AUDIO_CACHE_KEY)
|
||||
if isinstance(cached_key, str) and cached_key:
|
||||
ref_audio_cache_key = cached_key
|
||||
return ref_audio_cache_key
|
||||
wav_np, sr = _get_ref_audio()
|
||||
ref_audio_cache_key = self._make_ref_audio_cache_key(wav_np, sr)
|
||||
info_dict[_REF_AUDIO_CACHE_KEY] = ref_audio_cache_key
|
||||
return ref_audio_cache_key
|
||||
|
||||
def _try_get_ref_audio_cache_key() -> str | None:
|
||||
nonlocal ref_audio_cache_key
|
||||
if isinstance(ref_audio_cache_key, str) and ref_audio_cache_key:
|
||||
return ref_audio_cache_key
|
||||
cached_key = info_dict.get(_REF_AUDIO_CACHE_KEY)
|
||||
if isinstance(cached_key, str) and cached_key:
|
||||
ref_audio_cache_key = cached_key
|
||||
return ref_audio_cache_key
|
||||
ref_audio_list = info_dict.get("ref_audio")
|
||||
if isinstance(ref_audio_list, list) and ref_audio_list:
|
||||
return _get_ref_audio_cache_key()
|
||||
return None
|
||||
|
||||
# Speaker cache: only for uploaded (named) speakers
|
||||
_speaker_cache_key = None
|
||||
if voice_clone_prompt is None:
|
||||
@@ -1849,12 +1946,24 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
if isinstance(ref_code_t, torch.Tensor):
|
||||
ref_code_len = int(ref_code_t.shape[0])
|
||||
if ref_code_t is None and in_context_mode:
|
||||
# Compute ref_code from ref_audio if not provided.
|
||||
wav_np, sr = _get_ref_audio()
|
||||
ref_code_t = self._encode_ref_audio_to_code(wav_np, sr).to(device=input_ids.device)
|
||||
ref_code_len = int(ref_code_t.shape[0])
|
||||
cache_key = _get_ref_audio_cache_key()
|
||||
cached = self._get_ref_audio_artifacts(cache_key)
|
||||
cached_ref_code = None
|
||||
if isinstance(cached, dict):
|
||||
cached_ref_code = cached.get("ref_code")
|
||||
ref_code_t = self._coerce_ref_code_tensor(cached_ref_code, device=input_ids.device)
|
||||
if isinstance(ref_code_t, torch.Tensor):
|
||||
ref_code_len = int(ref_code_t.shape[0])
|
||||
else:
|
||||
wav_np, sr = _get_ref_audio()
|
||||
ref_code_t = self._encode_ref_audio_to_code(wav_np, sr).to(device=input_ids.device)
|
||||
ref_code_len = int(ref_code_t.shape[0])
|
||||
self._put_ref_audio_artifacts(cache_key, ref_code=ref_code_t)
|
||||
if isinstance(ref_code_t, torch.Tensor):
|
||||
ref_code_prompt = ref_code_t
|
||||
cache_key = _try_get_ref_audio_cache_key()
|
||||
if isinstance(cache_key, str) and cache_key:
|
||||
self._put_ref_audio_artifacts(cache_key, ref_code=ref_code_prompt)
|
||||
|
||||
# Speaker embedding: use prompt embed if provided; otherwise extract from audio.
|
||||
# NOTE: Do NOT use _as_singleton here — the embedding may be a plain
|
||||
@@ -1869,8 +1978,17 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
# Plain list/array from API (survived msgspec IPC serialization).
|
||||
speaker_embed = torch.tensor(spk, dtype=torch.bfloat16, device=input_ids.device).view(1, 1, -1)
|
||||
else:
|
||||
wav_np, sr = _get_ref_audio()
|
||||
speaker_embed = self._extract_speaker_embedding(wav_np, sr).view(1, 1, -1)
|
||||
cache_key = _get_ref_audio_cache_key()
|
||||
cached = self._get_ref_audio_artifacts(cache_key)
|
||||
cached_spk = None
|
||||
if isinstance(cached, dict):
|
||||
cached_spk = cached.get("ref_spk_embedding")
|
||||
if isinstance(cached_spk, torch.Tensor):
|
||||
speaker_embed = cached_spk.to(device=input_ids.device, dtype=torch.bfloat16).view(1, 1, -1)
|
||||
else:
|
||||
wav_np, sr = _get_ref_audio()
|
||||
speaker_embed = self._extract_speaker_embedding(wav_np, sr).view(1, 1, -1)
|
||||
self._put_ref_audio_artifacts(cache_key, ref_spk_embedding=speaker_embed)
|
||||
|
||||
# Cache miss: store extraction result
|
||||
if _speaker_cache_key is not None and speaker_embed is not None:
|
||||
|
||||
@@ -155,6 +155,11 @@ class NPUARModelRunner(OmniNPUModelRunner):
|
||||
finally:
|
||||
set_cudagraph_capturing_enabled(False)
|
||||
|
||||
def _model_needs_full_prefix_hidden_states(self) -> bool:
|
||||
"""See gpu_ar_model_runner._model_needs_full_prefix_hidden_states."""
|
||||
model = getattr(self, "model", None)
|
||||
return bool(getattr(model, "requires_full_prefix_cached_hidden_states", True))
|
||||
|
||||
def _maybe_update_prefix_cache(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
@@ -169,8 +174,9 @@ class NPUARModelRunner(OmniNPUModelRunner):
|
||||
type(multimodal_outputs),
|
||||
)
|
||||
|
||||
hs_for_cache = hidden_states if self._model_needs_full_prefix_hidden_states() else None
|
||||
self.omni_prefix_cache.update_omni_tensor_prefix_cache(
|
||||
hidden_states=hidden_states,
|
||||
hidden_states=hs_for_cache,
|
||||
multimodal_outputs=flatten_payload(multimodal_outputs) if multimodal_outputs else multimodal_outputs,
|
||||
num_tokens_unpadded=num_tokens_unpadded,
|
||||
slot_mapping=self.input_batch.block_table[0].slot_mapping.cpu,
|
||||
@@ -185,12 +191,13 @@ class NPUARModelRunner(OmniNPUModelRunner):
|
||||
) -> tuple[dict[str, torch.Tensor] | None, dict | None]:
|
||||
combined_hidden_states, combined_multimodal_outputs = None, None
|
||||
if self.omni_prefix_cache is not None:
|
||||
combined_hidden_states = self.omni_prefix_cache.get_merged_hidden_states(
|
||||
query_start_loc=self.query_start_loc.cpu,
|
||||
input_batch=self.input_batch,
|
||||
hidden_states=hidden_states,
|
||||
num_scheduled_tokens=num_scheduled_tokens,
|
||||
)
|
||||
if self._model_needs_full_prefix_hidden_states():
|
||||
combined_hidden_states = self.omni_prefix_cache.get_merged_hidden_states(
|
||||
query_start_loc=self.query_start_loc.cpu,
|
||||
input_batch=self.input_batch,
|
||||
hidden_states=hidden_states,
|
||||
num_scheduled_tokens=num_scheduled_tokens,
|
||||
)
|
||||
combined_multimodal_outputs = self.omni_prefix_cache.get_merged_multimodal_states(
|
||||
query_start_loc=self.query_start_loc.cpu,
|
||||
input_batch=self.input_batch,
|
||||
|
||||
@@ -68,12 +68,13 @@ def to_payload_element(
|
||||
if we need to avoid splitting nonempty lists prior to calling
|
||||
postprocess, which is the case for prefix cache.
|
||||
seq_len: Optional sequence length (i.e., dim 0 of hidden states).
|
||||
This should be set to None in the prefix caching case, because
|
||||
the condition that would be executed here is the same as the
|
||||
criteria for being added to the multimodal outputs cache.
|
||||
When set, a tensor whose first dimension equals seq_len is
|
||||
sliced per request. The prefix cache passthrough also passes
|
||||
the total scheduled token count here so 1D (seq_len,) metadata
|
||||
that is intentionally not cached is still split per request.
|
||||
"""
|
||||
# Prefix cache won't hit this case because this is the condition
|
||||
# for being a mm_cache_key in the multimodal outputs tensor.
|
||||
# Cached per-token tensors are merged elsewhere; here a first dim
|
||||
# equal to seq_len means a per-request slice is required.
|
||||
if seq_len is not None and isinstance(element, torch.Tensor) and element.shape[0] == seq_len:
|
||||
return element[start:end].contiguous()
|
||||
# Every other case is shared between prefix cache (passthrough data)
|
||||
|
||||
@@ -203,6 +203,24 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
|
||||
finally:
|
||||
set_cudagraph_capturing_enabled(False)
|
||||
|
||||
def _model_needs_full_prefix_hidden_states(self) -> bool:
|
||||
"""Opt-out hook for models whose postprocess only consumes the tail.
|
||||
|
||||
When False, we skip both the per-step GPU->CPU hidden-state write into
|
||||
OmniTensorPrefixCache and the merged-tensor reconstruction on hits;
|
||||
postprocess receives the normal scheduled-token slice instead. Models
|
||||
that need the full cached_prefix + new_tail span (default) are not
|
||||
affected.
|
||||
"""
|
||||
model = getattr(self, "model", None)
|
||||
return bool(getattr(model, "requires_full_prefix_cached_hidden_states", True))
|
||||
|
||||
def _deferred_prefix_cache_mm_keys(self) -> set[str]:
|
||||
"""Model-declared multimodal keys whose prefix-cache writes are deferred."""
|
||||
model = getattr(self, "model", None)
|
||||
keys = getattr(model, "deferred_prefix_cache_mm_keys", ())
|
||||
return set(keys or ())
|
||||
|
||||
def _maybe_update_prefix_cache(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
@@ -225,12 +243,14 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
|
||||
type(multimodal_outputs),
|
||||
)
|
||||
|
||||
hs_for_cache = hidden_states if self._model_needs_full_prefix_hidden_states() else None
|
||||
self.omni_prefix_cache.update_omni_tensor_prefix_cache(
|
||||
hidden_states=hidden_states,
|
||||
hidden_states=hs_for_cache,
|
||||
multimodal_outputs=flatten_payload(multimodal_outputs) if multimodal_outputs else multimodal_outputs,
|
||||
num_tokens_unpadded=num_tokens_unpadded,
|
||||
slot_mapping=self.input_batch.block_table[0].slot_mapping.cpu,
|
||||
num_tokens_padded=num_tokens_padded,
|
||||
skip_mm_cache_keys=self._deferred_prefix_cache_mm_keys(),
|
||||
)
|
||||
|
||||
def _maybe_get_combined_prefix_cache_tensors(
|
||||
@@ -246,12 +266,18 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
|
||||
# the prefix cached hidden states and multimodal states.
|
||||
combined_hidden_states, combined_multimodal_outputs = None, None
|
||||
if self.omni_prefix_cache is not None:
|
||||
combined_hidden_states = self.omni_prefix_cache.get_merged_hidden_states(
|
||||
query_start_loc=self.query_start_loc.cpu,
|
||||
input_batch=self.input_batch,
|
||||
hidden_states=hidden_states,
|
||||
num_scheduled_tokens=num_scheduled_tokens,
|
||||
)
|
||||
if (
|
||||
not self._model_needs_full_prefix_hidden_states()
|
||||
and not self.omni_prefix_cache.has_prefix_cached_new_req_ids()
|
||||
):
|
||||
return None, None
|
||||
if self._model_needs_full_prefix_hidden_states():
|
||||
combined_hidden_states = self.omni_prefix_cache.get_merged_hidden_states(
|
||||
query_start_loc=self.query_start_loc.cpu,
|
||||
input_batch=self.input_batch,
|
||||
hidden_states=hidden_states,
|
||||
num_scheduled_tokens=num_scheduled_tokens,
|
||||
)
|
||||
combined_multimodal_outputs = self.omni_prefix_cache.get_merged_multimodal_states(
|
||||
query_start_loc=self.query_start_loc.cpu,
|
||||
input_batch=self.input_batch,
|
||||
@@ -313,6 +339,12 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
|
||||
if flush_ids:
|
||||
self.flush_full_payload_outputs(flush_ids)
|
||||
|
||||
if self.omni_prefix_cache is not None and scheduler_output.finished_req_ids:
|
||||
self.omni_prefix_cache.commit_deferred_mm_outputs(
|
||||
set(scheduler_output.finished_req_ids),
|
||||
self.input_batch,
|
||||
)
|
||||
|
||||
if self.routed_experts_initialized:
|
||||
capturer = get_global_experts_capturer()
|
||||
if capturer is not None and hasattr(capturer, "finalize_pending_copy"):
|
||||
@@ -884,9 +916,22 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
|
||||
dtype=np.int32,
|
||||
)
|
||||
query_start_loc_cpu = self.query_start_loc.cpu
|
||||
if self.omni_prefix_cache is not None:
|
||||
deferred_mm_cache_keys = self._deferred_prefix_cache_mm_keys()
|
||||
if deferred_mm_cache_keys:
|
||||
self.omni_prefix_cache.stage_deferred_mm_outputs(
|
||||
query_start_loc=query_start_loc_cpu,
|
||||
input_batch=self.input_batch,
|
||||
multimodal_outputs=(
|
||||
flatten_payload(multimodal_outputs) if multimodal_outputs else multimodal_outputs
|
||||
),
|
||||
num_scheduled_tokens=scheduler_output.num_scheduled_tokens,
|
||||
deferred_mm_cache_keys=deferred_mm_cache_keys,
|
||||
)
|
||||
|
||||
pooler_output: list[dict[str, object]] | None = None
|
||||
if needs_pooler_payload:
|
||||
mm_cpu = None
|
||||
# Prior to applying the post-processing func, extract
|
||||
# the prefix cached hidden states and multimodal states.
|
||||
if self.omni_prefix_cache is not None:
|
||||
@@ -899,8 +944,8 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
|
||||
scheduler_output.num_scheduled_tokens,
|
||||
)
|
||||
# Otherwise we don't have the mm CPU data yet, so we still need to build it
|
||||
if self.omni_prefix_cache is None:
|
||||
mm_cpu = build_mm_cpu(flatten_payload(multimodal_outputs))
|
||||
if self.omni_prefix_cache is None or combined_multimodal_outputs is None:
|
||||
mm_cpu = build_mm_cpu(flatten_payload(multimodal_outputs) if multimodal_outputs else multimodal_outputs)
|
||||
|
||||
self._process_additional_information_updates(
|
||||
hidden_states,
|
||||
|
||||
@@ -332,6 +332,8 @@ class OmniGPUModelRunner(GPUModelRunner):
|
||||
self.requests.pop(req_id, None)
|
||||
self.model_intermediate_buffer.pop(req_id, None)
|
||||
self.num_prompt_logprobs.pop(req_id, None)
|
||||
if self.omni_prefix_cache is not None:
|
||||
self.omni_prefix_cache.discard_deferred_mm_outputs(req_id)
|
||||
if hasattr(self, "_downstream_payload_cache"):
|
||||
self._downstream_payload_cache.pop(req_id, None)
|
||||
if hasattr(self, "_talker_mtp_generators"):
|
||||
|
||||
Reference in new issue
Block a user