Skip to content

Commit 6dcf80d

Browse files
committed
add tests for marker replaced with cache disabled
Signed-off-by: aviv ron <rona@il.ibm.com>
1 parent 2cefb0a commit 6dcf80d

1 file changed

Lines changed: 283 additions & 0 deletions

File tree

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""Audio serving with vLLM's multimodal processor cache DISABLED.
3+
4+
This is the configuration that actually exercises
5+
``AudioMultiModalProcessor._hf_processor_applies_updates``. vLLM reaches the HF
6+
processor two ways and only one of them consults that hook:
7+
8+
* cached path — hardcodes ``is_update_applied = False``, never asks
9+
* uncached path — takes ``is_update_applied`` from the hook
10+
11+
Every other audio test runs vLLM's defaults, where the cache is on, so they pass
12+
whether or not the hook is overridden. With the cache off and the base hook's
13+
``True``, vLLM skips applying our ``PromptReplacement`` and then reports the item
14+
as missing::
15+
16+
RuntimeError: Expected there to be 1 audio prompt placeholders corresponding
17+
to 1 audio items, but instead found 0 prompt placeholders!
18+
19+
Startup profiling passes a *string* prompt, so on the uncached path the engine
20+
fails before it can serve anything — booting at all is a large part of the guard.
21+
22+
Assertions here are structural (request completes, marker was replaced), never
23+
transcript content: ASR output is not deterministic enough to assert on.
24+
25+
Opt in explicitly: `pytest -m "slow and requires_model and gpu"`.
26+
"""
27+
28+
import importlib.util
29+
import os
30+
31+
import pytest
32+
33+
pytestmark = [
34+
pytest.mark.audio,
35+
pytest.mark.slow,
36+
pytest.mark.requires_model,
37+
pytest.mark.gpu,
38+
]
39+
40+
if importlib.util.find_spec("vllm") is None:
41+
pytest.skip("requires vLLM installed", allow_module_level=True)
42+
43+
44+
# One small model: this covers a *configuration* dimension, not a model matrix,
45+
# and each engine boot here is expensive.
46+
_BASE_MODEL = "ibm-granite/granite-4.0-micro"
47+
_ADAPTER_LIBRARY = "ibm-granite/granitelib-core-r1.0"
48+
49+
COMPOSE_TIMEOUT_S = 1800 # matches the sibling E2E fixtures
50+
_TARGET_SR = 16_000
51+
_AUDIO_MARKER = "<|audio|>"
52+
53+
54+
def _cache_disabling_kwargs():
55+
"""LLM kwargs that turn off the multimodal processor cache.
56+
57+
The knob was renamed across the vLLM range this project supports (0.19.x and
58+
0.20.x are both allowed in pyproject): older builds expose
59+
``disable_mm_preprocessor_cache``, newer ones ``mm_processor_cache_gb``.
60+
Returns an empty dict when neither exists, so the caller can skip rather than
61+
silently exercise the cached path.
62+
"""
63+
import dataclasses
64+
65+
from vllm.engine.arg_utils import EngineArgs
66+
67+
names = {f.name for f in dataclasses.fields(EngineArgs)}
68+
if "mm_processor_cache_gb" in names:
69+
return {"mm_processor_cache_gb": 0}
70+
if "disable_mm_preprocessor_cache" in names:
71+
return {"disable_mm_preprocessor_cache": True}
72+
return {}
73+
74+
75+
def _cache_disabled_state(llm):
76+
"""Whether the running engine really has the processor cache off.
77+
78+
``True``/``False`` when determinable, ``None`` when this vLLM exposes neither
79+
setting where we look. The field moved: it lives on ``MultiModalConfig``
80+
(nested under ``model_config.multimodal_config``) in newer vLLM, so checking
81+
only ``model_config`` silently answers "unknown" and the whole module would
82+
look green while running the cached path — testing nothing.
83+
"""
84+
model_config = llm.llm_engine.model_config
85+
for obj in (getattr(model_config, "multimodal_config", None), model_config):
86+
if obj is None:
87+
continue
88+
if hasattr(obj, "mm_processor_cache_gb"):
89+
return obj.mm_processor_cache_gb == 0
90+
if hasattr(obj, "disable_mm_preprocessor_cache"):
91+
return bool(obj.disable_mm_preprocessor_cache)
92+
return None
93+
94+
95+
@pytest.fixture(scope="module")
96+
def audio_checkpoint(tmp_path_factory):
97+
"""Compose one audio-enabled checkpoint through the compose CLI."""
98+
import subprocess
99+
import sys
100+
101+
save_dir = tmp_path_factory.mktemp(_BASE_MODEL.rsplit("/", 1)[-1]) / "model"
102+
cmd = [
103+
sys.executable,
104+
"-m",
105+
"granite_switch.composer.compose_granite_switch",
106+
"--base-model",
107+
_BASE_MODEL,
108+
"--adapters",
109+
_ADAPTER_LIBRARY,
110+
"--enable-audio",
111+
"--output",
112+
str(save_dir),
113+
]
114+
result = subprocess.run(
115+
cmd, capture_output=True, text=True, timeout=COMPOSE_TIMEOUT_S
116+
)
117+
if result.returncode != 0:
118+
raise RuntimeError(
119+
f"compose failed for base={_BASE_MODEL} adapter={_ADAPTER_LIBRARY}\n"
120+
f"--- STDOUT ---\n{result.stdout}\n--- STDERR ---\n{result.stderr}"
121+
)
122+
return save_dir
123+
124+
125+
@pytest.fixture(scope="module")
126+
def served_uncached(audio_checkpoint):
127+
"""Boot vLLM with the processor cache off, and prove it is off.
128+
129+
Reaching the ``yield`` is itself the startup-profiling guard: profiling runs a
130+
dummy audio item through full processing with a string prompt, exactly the
131+
combination that fails when the hook is left at its default.
132+
133+
The cache check lives here rather than in a test so it *gates* every case. As
134+
a separate test it would only skip itself, leaving the rest of the module
135+
green while silently running the cached path.
136+
"""
137+
import gc
138+
139+
import torch
140+
141+
cache_kwargs = _cache_disabling_kwargs()
142+
if not cache_kwargs:
143+
pytest.skip(
144+
"installed vLLM exposes neither mm_processor_cache_gb nor "
145+
"disable_mm_preprocessor_cache; cannot disable the processor cache"
146+
)
147+
148+
os.environ.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
149+
from vllm import LLM
150+
151+
llm = LLM(
152+
model=str(audio_checkpoint),
153+
dtype="bfloat16",
154+
gpu_memory_utilization=0.7,
155+
enforce_eager=True, # boot speed; orthogonal to the cache setting
156+
**cache_kwargs,
157+
)
158+
try:
159+
state = _cache_disabled_state(llm)
160+
if state is False:
161+
pytest.fail(
162+
f"{cache_kwargs!r} was accepted but the processor cache is still "
163+
f"enabled; this module would exercise the cached path instead"
164+
)
165+
if state is None:
166+
pytest.skip(
167+
"cannot confirm the processor-cache setting on this vLLM, so this "
168+
"module cannot establish that it is testing the uncached path"
169+
)
170+
yield {
171+
"llm": llm,
172+
"config": llm.llm_engine.model_config.hf_config,
173+
"tokenizer": llm.get_tokenizer(),
174+
}
175+
finally:
176+
del llm
177+
gc.collect()
178+
torch.cuda.empty_cache()
179+
180+
181+
@pytest.fixture(scope="module")
182+
def marker_id(served_uncached):
183+
"""The ``<|audio|>`` token id, verified to be a real single token.
184+
185+
``convert_tokens_to_ids`` returns the *unk* id for an unregistered token
186+
rather than None, so a checkpoint composed without audio would hand back a
187+
plausible-looking id and the marker assertions would be meaningless.
188+
"""
189+
tokenizer = served_uncached["tokenizer"]
190+
token_id = tokenizer.convert_tokens_to_ids(_AUDIO_MARKER)
191+
unk_id = getattr(tokenizer, "unk_token_id", None)
192+
assert token_id is not None and token_id >= 0, (
193+
f"{_AUDIO_MARKER} is not in the tokenizer"
194+
)
195+
assert token_id != unk_id, (
196+
f"{_AUDIO_MARKER} resolved to the unk id ({unk_id}) — the checkpoint was "
197+
f"not composed with --enable-audio"
198+
)
199+
encoded = tokenizer.encode(_AUDIO_MARKER, add_special_tokens=False)
200+
assert encoded == [token_id], (
201+
f"{_AUDIO_MARKER} does not encode to exactly one token: {encoded}"
202+
)
203+
return token_id
204+
205+
206+
def _tone(seconds: float = 1.0, freq: float = 440.0):
207+
"""Deterministic mono 16 kHz waveform (no speech fixture needed)."""
208+
import numpy as np
209+
210+
t = np.arange(int(seconds * _TARGET_SR), dtype=np.float32) / _TARGET_SR
211+
return (0.1 * np.sin(2 * np.pi * freq * t)).astype(np.float32)
212+
213+
214+
def _silence(seconds: float = 1.0):
215+
"""A clip with no speech in it at all."""
216+
import numpy as np
217+
218+
return np.zeros(int(seconds * _TARGET_SR), dtype=np.float32)
219+
220+
221+
def _generate_with_audio(llm, waveform):
222+
from vllm import SamplingParams
223+
224+
return llm.generate(
225+
{
226+
"prompt": f"{_AUDIO_MARKER} What was said?",
227+
"multi_modal_data": {"audio": [(waveform, _TARGET_SR)]},
228+
},
229+
SamplingParams(max_tokens=8, temperature=0.0),
230+
)
231+
232+
233+
def _assert_marker_replaced(outputs, marker_id):
234+
"""The request completed and the marker is gone from the final prompt.
235+
236+
``RequestOutput.prompt_token_ids`` is the post-processing prompt (vLLM builds
237+
the engine request from the processed inputs), so an absent marker means the
238+
prompt replacement really was applied. Length is not asserted: a clip with no
239+
speech legitimately collapses to the one-token fallback.
240+
"""
241+
assert len(outputs) == 1
242+
assert len(outputs[0].outputs[0].token_ids) >= 1
243+
244+
prompt_ids = list(outputs[0].prompt_token_ids or [])
245+
assert prompt_ids, "engine returned no prompt_token_ids to inspect"
246+
assert marker_id not in prompt_ids, (
247+
f"marker id {marker_id} survived into the final prompt — the audio "
248+
f"placeholder was not applied"
249+
)
250+
251+
252+
def test_audio_request_splices_transcript_uncached(served_uncached, marker_id):
253+
"""An audio request completes and the marker is replaced, cache off.
254+
255+
The direct positive signal for the hook override: with the base hook the
256+
replacement is skipped and vLLM raises before returning anything.
257+
"""
258+
outputs = _generate_with_audio(served_uncached["llm"], _tone())
259+
_assert_marker_replaced(outputs, marker_id)
260+
261+
262+
def test_silent_clip_uncached(served_uncached, marker_id):
263+
"""Silence still yields a usable placeholder on the uncached path.
264+
265+
The empty-transcript fallback fires on both processor paths, so it is worth
266+
pinning here too: a clip transcribing to "" must not collapse to a
267+
zero-length placeholder.
268+
"""
269+
outputs = _generate_with_audio(served_uncached["llm"], _silence())
270+
_assert_marker_replaced(outputs, marker_id)
271+
272+
273+
def test_text_only_request_uncached(served_uncached):
274+
"""Disabling the cache must not disturb ordinary text requests."""
275+
from vllm import SamplingParams
276+
277+
outputs = served_uncached["llm"].generate(
278+
"The capital of France is",
279+
SamplingParams(max_tokens=8, temperature=0.0),
280+
)
281+
282+
assert len(outputs) == 1
283+
assert len(outputs[0].outputs[0].token_ids) >= 1

0 commit comments

Comments
 (0)