[Test] Add prefix caching + audio output regression test (#3510) (#3604)

Signed-off-by: Ricardo Noriega De Soto <rnoriega@redhat.com>
Signed-off-by: Alex Brooks <albrooks@redhat.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Alex Brooks <albrooks@redhat.com>
This commit is contained in:
authored and GitHub committed 2026-06-03 09:17:53 +00:00
1 parent d2e65a92f1
commit 07737e6c61
3 files changed
+109 -104

No files matched your search

+90 -94
View File
@@ -9,63 +9,40 @@ import pytest
from tests.helpers.mark import hardware_test
from tests.helpers.media import generate_synthetic_audio, generate_synthetic_image, generate_synthetic_video
from tests.helpers.runtime import OmniServerParams, dummy_messages_from_mix_data
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
from vllm_omni.platforms import current_omni_platform
from tests.helpers.stage_config import get_deploy_config_path
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
models = ["Qwen/Qwen3-Omni-30B-A3B-Instruct"]
# Set VLLM_TEST_PD_MODE=1 to test PD disaggregation (follow-up — deploy overlay not yet migrated).
_USE_PD = os.environ.get("VLLM_TEST_PD_MODE", "0") == "1"
_MODEL = "Qwen/Qwen3-Omni-30B-A3B-Instruct"
_CI_DEPLOY = get_deploy_config_path("ci/qwen3_omni_moe.yaml")
def get_chunk_config(config_path: str | None = None):
"""Load the qwen3_omni CI deploy yaml with async_chunk modifications for streaming mode."""
if config_path is None:
config_path = _CI_DEPLOY
# TODO: remove this workaround once legacy `stage_args` path is deleted.
# The pipeline (qwen3_omni/pipeline.py) already wires
# thinker2talker_async_chunk / talker2code2wav_async_chunk on stage 0/1,
# so only async_chunk needs flipping. Writing nested `engine_args:` into
# the new-schema overlay trips _parse_stage_deploy's legacy branch and
# drops flat fields (load_format, max_num_seqs, ...).
return modify_stage_config(config_path, updates={"async_chunk": True})
# Platform-specific overrides live inside the new deploy yaml's ``platforms:``
# section, so a single ``_CI_DEPLOY`` path serves CUDA, ROCm, and XPU.
# TODO: re-add VLLM_TEST_PD_MODE branch once the PD-disaggregation deploy
# overlay has been migrated to the new schema (previously used the deleted
# ``qwen3_omni_moe_pd_ci.yaml`` stage-configs file).
if current_omni_platform.is_xpu():
stage_configs = [_CI_DEPLOY]
else: # CUDA + ROCm MI325 share the same deploy config
stage_configs = [get_chunk_config()]
# Create parameter combinations for model and stage config
test_params = [
OmniServerParams(model=model, stage_config_path=stage_config) for model in models for stage_config in stage_configs
]
# For prefix caching, we enable it on the thinker (stage 0) via CLI override
# For prefix caching checks against we enable it on the thinker and talker via CLI override
# and enable prompt token details so that we can determine if any tokens were cached.
# We also explicitly set block size so that we can make sure the cached token counts are a
# multiple of the block size.
BLOCK_SIZE = 16
prefix_test_params = [
OmniServerParams(
model=model,
stage_config_path=_CI_DEPLOY,
server_args=[
"--block-size",
str(BLOCK_SIZE),
"--stage-overrides",
'{"0": {"enable_prefix_caching": true}}',
"--enable-prompt-tokens-details",
],
test_params = [
pytest.param(
OmniServerParams(
model=_MODEL,
stage_config_path=_CI_DEPLOY,
use_stage_cli=True,
server_args=[
"--no-async-chunk",
"--block-size",
str(BLOCK_SIZE),
"--stage-overrides",
'{"0": {"enable_prefix_caching": true}, "1": {"enable_prefix_caching": true}}',
"--enable-prompt-tokens-details",
],
),
id="default",
)
for model in models
]
@@ -166,45 +143,14 @@ def test_text_to_text_001(omni_server, openai_client) -> None:
openai_client.send_omni_request(request_config, request_num=get_max_batch_size())
@pytest.mark.advanced_model
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", prefix_test_params, indirect=True)
def test_thinker_prefix_caching(omni_server, openai_client, run_level) -> None:
def _run_prefix_cache_check(openai_client, request_config: dict):
"""Make two requests given a request config, and validate that:
1. The second request actually had cached tokens
2. The number of cached tokens is divisible by the block size used in
test_params, because currently upstream vLLM does not cache partial
blocks.
"""
Test thinker prefix caching by sending identical requests with an image (i.e.,
a large shared prefix) and verifying that the second request uses cached tokens
& produces the same output with greedy decoding.
NOTE: The reason that we check against logprobs instead of direct text here is that
the outputs may still diverge a bit even though we set the seed and temperature.
This is mostly because the GEMM algorithm may vary based on the input tensors dims.
Because of this, we don't check the logprobs if it's a dummy load, since in that case
the top logprobs will all be very close.
"""
seed = 10
img_res = generate_synthetic_image(224, 224, seed=seed)
image_data_url = f"data:image/jpeg;base64,{img_res['base64']}"
messages = dummy_messages_from_mix_data(
system_prompt=get_system_prompt(),
image_data_url=image_data_url,
content_text=get_prompt("text_image"),
)
top_k = 10
sampling_params = {"seed": seed, "temperature": 0, "max_tokens": 8, "logprobs": top_k}
request_config = {
"model": omni_server.model,
"messages": messages,
"stream": False,
"modalities": ["text"],
"logprobs": True,
"top_logprobs": top_k,
"sampling_params_list": [sampling_params] * 3,
}
uncached_response = openai_client.send_omni_request(request_config, request_num=1)[0]
openai_client.send_omni_request(request_config, request_num=1)[0]
cached_response = openai_client.send_omni_request(request_config, request_num=1)[0]
# Ensure that we have a prefix cache hit on the second request and that only the last
@@ -217,16 +163,66 @@ def test_thinker_prefix_caching(omni_server, openai_client, run_level) -> None:
assert num_cached_tokens % BLOCK_SIZE == 0
assert (num_cached_tokens + num_uncached_tokens) == num_prompt_tokens
# Ensure that we have logprobs and tokens were generated for both requests
assert uncached_response.logprobs is not None
assert cached_response.logprobs is not None
n_tokens = min(len(uncached_response.logprobs), len(cached_response.logprobs))
assert n_tokens > 0
if run_level == "advanced_model":
# For each token index where both responses have an output, ensure that the greedy token
# predicted in the uncached case is in the top k logprobs for the cached case
for idx in range(n_tokens):
greedy_token = uncached_response.logprobs[idx].token
cached_top_k = {lp.token for lp in cached_response.logprobs[idx].top_logprobs}
assert greedy_token in cached_top_k
@pytest.mark.advanced_model
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_thinker_prefix_caching_text_output(omni_server, openai_client) -> None:
"""
Test thinker prefix caching by sending identical requests with an image (i.e.,
a large shared prefix) and verifying that the second request uses cached tokens
& produces the same output with greedy decoding.
NOTE: Checking the output of prefix caching directly can be a bit unstable
due to slight numerical differences as a result of running different scheduled
sequence lengths. As such, for E2E tests on prefix cache, we only check the cached
token count and not the output, since the omni tensor cache has solid unit tests,
and the core prefix cache algorithm is largely tested by upstream vLLM.
"""
img_res = generate_synthetic_image(224, 224)
image_data_url = f"data:image/jpeg;base64,{img_res['base64']}"
messages = dummy_messages_from_mix_data(
system_prompt=get_system_prompt(),
image_data_url=image_data_url,
content_text=get_prompt("text_image"),
)
request_config = {
"model": omni_server.model,
"messages": messages,
"stream": False,
"modalities": ["text"],
}
_run_prefix_cache_check(openai_client, request_config)
@pytest.mark.advanced_model
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_thinker_prefix_caching_audio_output(omni_server, openai_client) -> None:
"""
Verify that thinker prefix caching does not hang when the request
produces audio output (text + audio modalities). Sends two identical
requests so the second exercises the prefix-cached path through the
full thinker -> talker -> code2wav pipeline.
Regression test for https://github.com/vllm-project/vllm-omni/issues/3510
"""
messages = dummy_messages_from_mix_data(
system_prompt=get_system_prompt(),
content_text=get_prompt(),
)
request_config = {
"model": omni_server.model,
"messages": messages,
"stream": True,
"stream_options": {
"include_usage": True,
},
}
_run_prefix_cache_check(openai_client, request_config)
+8
View File
@@ -888,6 +888,12 @@ class OpenAIClientHandler:
audio_data.append(content)
elif modality == "text" and content:
text_content += content
# Usage is yielded after the last token
if chunk.usage:
result.prompt_tokens = chunk.usage.prompt_tokens
if details := getattr(chunk.usage, "prompt_tokens_details", None):
result.cached_tokens = details.cached_tokens
audio_content = None
if audio_data:
merged_seg = _merge_base64_audio_to_segment(audio_data)
@@ -1582,6 +1588,8 @@ class OpenAIClientHandler:
create_kwargs["logprobs"] = request_config["logprobs"]
if "top_logprobs" in request_config:
create_kwargs["top_logprobs"] = request_config["top_logprobs"]
if "stream_options" in request_config:
create_kwargs["stream_options"] = request_config["stream_options"]
if extra_body:
create_kwargs["extra_body"] = extra_body
+11 -10
View File
@@ -1065,11 +1065,6 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
if omni_res.metrics:
last_metrics = omni_res.metrics
if res.prompt_token_ids is not None:
num_prompt_tokens = len(res.prompt_token_ids)
if res.encoder_prompt_token_ids is not None:
num_prompt_tokens += len(res.encoder_prompt_token_ids)
# Initialize role before conditional blocks to avoid UnboundLocalError
# when handling audio/image responses
role = self.get_chat_request_role(request)
@@ -1081,6 +1076,14 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
# the result_generator, it needs to be sent as the FIRST
# response (by the try...catch).
if first_iteration_dict[final_output_type] and final_output_type == "text":
# NOTE: prompt token IDs / cached tokens are based on the first iteration
# of the stage that producing text output for consistency for current
# non-stream behaviors.
if res.prompt_token_ids is not None:
num_prompt_tokens = len(res.prompt_token_ids)
if res.encoder_prompt_token_ids is not None:
num_prompt_tokens += len(res.encoder_prompt_token_ids)
num_cached_tokens = res.num_cached_tokens
# Send first response for each choice with role
# NOTE: num_choices defaults to 1 so this usually executes once per request
@@ -1703,11 +1706,9 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
model=model_name,
modality=final_output_type,
)
chunk.usage = UsageInfo(
prompt_tokens=num_prompt_tokens,
completion_tokens=0,
total_tokens=num_prompt_tokens,
)
# NOTE: Currently usage is only set the text stages to align with the behavior
# of the full generator. TODO (Alex): Add support for usage on all stages for
# both streaming and non-streaming.
data = chunk.model_dump_json(exclude_unset=True)
yield f"data: {data}\n\n"