|
| 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