|
| 1 | +# SPDX-License-Identifier: Apache-2.0 |
| 2 | +"""End-to-end vLLM serving smoke for the audio cascade (issue #47). |
| 3 | +
|
| 4 | +Boots a real composed, audio-enabled GraniteSwitch checkpoint under vLLM and |
| 5 | +drives the three request shapes the #47 checklist calls out as the serving |
| 6 | +smoke — **text + adapter + audio x1/x2/x3** — through one live engine. This is |
| 7 | +the path the low-level tests deliberately bypass (CUDA graphs, torch.compile, |
| 8 | +the ASR processor running inside vLLM's EngineCore subprocess, the multi-clip |
| 9 | +splice, and generation), so it is the only test that proves those pieces |
| 10 | +compose at serve time. |
| 11 | +
|
| 12 | +Scope is a *smoke*: each request must complete and return well-formed output, |
| 13 | +and multi-clip requests must be accepted up to the checkpoint's declared clip |
| 14 | +ceiling. It intentionally does NOT assert transcript content — transcription |
| 15 | +quality (WER) and adapter-routing correctness are separate #47 boxes covered by |
| 16 | +the eval harness and `test_switch_e2e_compose.py` respectively. Synthetic audio |
| 17 | +keeps the test asset-free; a silent/tonal clip exercises the serving plumbing |
| 18 | +without shipping a speech fixture. |
| 19 | +
|
| 20 | +Model construction goes through the compose CLI (CLAUDE.md gotcha #5): no test |
| 21 | +hand-assembles a config. Audio is enabled with `--enable-audio`, which defaults |
| 22 | +the ASR front-end to the small built-in model (distil-whisper/distil-small.en) |
| 23 | +to keep CI cost down. |
| 24 | +
|
| 25 | +Markers: @pytest.mark.slow + @pytest.mark.requires_model + @pytest.mark.gpu. |
| 26 | +CI must opt in explicitly: `pytest -m "slow and requires_model and gpu"`. |
| 27 | +""" |
| 28 | + |
| 29 | +import importlib.util |
| 30 | +import json |
| 31 | +import os |
| 32 | + |
| 33 | +import pytest |
| 34 | + |
| 35 | +pytestmark = [pytest.mark.slow, pytest.mark.requires_model, pytest.mark.gpu] |
| 36 | + |
| 37 | +if importlib.util.find_spec("vllm") is None: |
| 38 | + pytest.skip("requires vLLM installed", allow_module_level=True) |
| 39 | + |
| 40 | + |
| 41 | +# ---------------------------------------------------------------------------- |
| 42 | +# Base-model / adapter-library pairs — kept in lockstep with |
| 43 | +# tests/integration/test_switch_e2e_compose.py so the two E2E files exercise |
| 44 | +# the same model matrix. A fast CI profile can pin one pair via -k or the |
| 45 | +# experimental env var below. |
| 46 | +# ---------------------------------------------------------------------------- |
| 47 | + |
| 48 | +_DEFAULT_BASE_MODEL_PAIRS = [ |
| 49 | + ("ibm-granite/granite-4.0-micro", "ibm-granite/granitelib-core-r1.0"), |
| 50 | + ("ibm-granite/granite-4.1-3b", "ibm-granite/granitelib-core-r1.0"), |
| 51 | +] |
| 52 | + |
| 53 | + |
| 54 | +def _load_experimental_pairs(): |
| 55 | + """Local/experimental pairings from GRANITE_SWITCH_EXPERIMENTAL_MODEL_PAIRS. |
| 56 | +
|
| 57 | + JSON array of {"base": str, "adapter": str}; base/adapter may be HF ids or |
| 58 | + local paths. The mechanism is committed; the values are not. Mirrors |
| 59 | + test_switch_e2e_compose.py so both E2E files share one extension knob. |
| 60 | + """ |
| 61 | + raw = os.environ.get("GRANITE_SWITCH_EXPERIMENTAL_MODEL_PAIRS", "") |
| 62 | + if not raw: |
| 63 | + return [] |
| 64 | + try: |
| 65 | + entries = json.loads(raw) |
| 66 | + except json.JSONDecodeError as e: |
| 67 | + raise ValueError( |
| 68 | + f"GRANITE_SWITCH_EXPERIMENTAL_MODEL_PAIRS is not valid JSON: {e}\n" |
| 69 | + f'Expected format: \'[{{"base":"/path","adapter":"/path"}}, ...]\'' |
| 70 | + ) |
| 71 | + return [(p["base"], p["adapter"]) for p in entries] |
| 72 | + |
| 73 | + |
| 74 | +BASE_MODEL_PAIRS = _DEFAULT_BASE_MODEL_PAIRS + _load_experimental_pairs() |
| 75 | + |
| 76 | +COMPOSE_TIMEOUT_S = 1800 # 30 min — matches test_switch_e2e_compose.py |
| 77 | +_TARGET_SR = 16_000 |
| 78 | + |
| 79 | + |
| 80 | +@pytest.fixture( |
| 81 | + scope="module", |
| 82 | + params=BASE_MODEL_PAIRS, |
| 83 | + ids=lambda p: p[0].rsplit("/", 1)[-1], |
| 84 | +) |
| 85 | +def audio_checkpoint(request, tmp_path_factory): |
| 86 | + """Compose one audio-enabled checkpoint per (base, adapter) pair. |
| 87 | +
|
| 88 | + Delegates to the compose CLI (same as test_switch_e2e_compose.py) with |
| 89 | + `--enable-audio`, then returns the save dir. Module scope amortizes the |
| 90 | + download-dominated first run across every smoke case for the pair. |
| 91 | + """ |
| 92 | + import subprocess |
| 93 | + import sys |
| 94 | + |
| 95 | + base_model, adapter_library = request.param |
| 96 | + save_dir = tmp_path_factory.mktemp(base_model.rsplit("/", 1)[-1]) / "model" |
| 97 | + |
| 98 | + cmd = [ |
| 99 | + sys.executable, |
| 100 | + "-m", |
| 101 | + "granite_switch.composer.compose_granite_switch", |
| 102 | + "--base-model", |
| 103 | + base_model, |
| 104 | + "--adapters", |
| 105 | + adapter_library, |
| 106 | + "--enable-audio", |
| 107 | + "--output", |
| 108 | + str(save_dir), |
| 109 | + ] |
| 110 | + result = subprocess.run( |
| 111 | + cmd, capture_output=True, text=True, timeout=COMPOSE_TIMEOUT_S |
| 112 | + ) |
| 113 | + if result.returncode != 0: |
| 114 | + raise RuntimeError( |
| 115 | + f"compose failed for base={base_model} adapter={adapter_library}\n" |
| 116 | + f"--- STDOUT ---\n{result.stdout}\n--- STDERR ---\n{result.stderr}" |
| 117 | + ) |
| 118 | + return {"base_model": base_model, "save_dir": save_dir} |
| 119 | + |
| 120 | + |
| 121 | +@pytest.fixture(scope="module") |
| 122 | +def served(audio_checkpoint): |
| 123 | + """Boot vLLM once for the checkpoint and share it across smoke cases. |
| 124 | +
|
| 125 | + Tokenizer init stays ON (unlike the argmax-equivalence test): the ASR |
| 126 | + processor needs a tokenizer to encode both the prompt and the transcript, |
| 127 | + and the adapter case needs it to tokenize text around the control token. |
| 128 | + """ |
| 129 | + import gc |
| 130 | + |
| 131 | + import torch |
| 132 | + |
| 133 | + os.environ.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn") |
| 134 | + from vllm import LLM |
| 135 | + |
| 136 | + llm = LLM( |
| 137 | + model=str(audio_checkpoint["save_dir"]), |
| 138 | + dtype="bfloat16", |
| 139 | + gpu_memory_utilization=0.7, |
| 140 | + enforce_eager=True, # smoke: skip CUDA-graph capture for a faster boot |
| 141 | + ) |
| 142 | + try: |
| 143 | + yield {"llm": llm, "config": llm.llm_engine.model_config.hf_config} |
| 144 | + finally: |
| 145 | + del llm |
| 146 | + gc.collect() |
| 147 | + torch.cuda.empty_cache() |
| 148 | + |
| 149 | + |
| 150 | +def _tone(seconds: float = 1.0, freq: float = 440.0): |
| 151 | + """Deterministic mono 16 kHz waveform (no speech fixture needed).""" |
| 152 | + import numpy as np |
| 153 | + |
| 154 | + t = np.arange(int(seconds * _TARGET_SR), dtype=np.float32) / _TARGET_SR |
| 155 | + return (0.1 * np.sin(2 * np.pi * freq * t)).astype(np.float32) |
| 156 | + |
| 157 | + |
| 158 | +def _one_completion(outputs): |
| 159 | + """Assert a single RequestOutput carrying at least one generated token.""" |
| 160 | + assert len(outputs) == 1 |
| 161 | + completion = outputs[0].outputs[0] |
| 162 | + assert len(completion.token_ids) >= 1 |
| 163 | + return completion |
| 164 | + |
| 165 | + |
| 166 | +def test_text_only_serving(served): |
| 167 | + """Baseline: a plain text request serves normally (backward-compat).""" |
| 168 | + from vllm import SamplingParams |
| 169 | + |
| 170 | + outputs = served["llm"].generate( |
| 171 | + "The capital of France is", |
| 172 | + SamplingParams(max_tokens=8, temperature=0.0), |
| 173 | + ) |
| 174 | + _one_completion(outputs) |
| 175 | + |
| 176 | + |
| 177 | +def test_adapter_control_token_serving(served): |
| 178 | + """An adapter control token routes through the switch under serving. |
| 179 | +
|
| 180 | + Correctness of the routing is `test_switch_e2e_compose.py`'s job; here we |
| 181 | + only prove the switch path runs end-to-end in the live engine without |
| 182 | + crashing and still generates. |
| 183 | + """ |
| 184 | + from vllm import SamplingParams |
| 185 | + from vllm.inputs import TokensPrompt |
| 186 | + |
| 187 | + config = served["config"] |
| 188 | + if not getattr(config, "adapter_token_ids", None): |
| 189 | + pytest.skip("composed checkpoint has no adapters") |
| 190 | + |
| 191 | + tokenizer = served["llm"].get_tokenizer() |
| 192 | + text_ids = tokenizer.encode("Summarize the document.", add_special_tokens=False) |
| 193 | + # LORA control tokens sit at the sequence start (CLAUDE.md gotcha #3). |
| 194 | + prompt = TokensPrompt(prompt_token_ids=[config.adapter_token_ids[0], *text_ids]) |
| 195 | + |
| 196 | + outputs = served["llm"].generate( |
| 197 | + prompt, SamplingParams(max_tokens=8, temperature=0.0) |
| 198 | + ) |
| 199 | + _one_completion(outputs) |
| 200 | + |
| 201 | + |
| 202 | +@pytest.mark.parametrize("num_clips", [1, 2, 3]) |
| 203 | +def test_audio_clip_serving(served, num_clips): |
| 204 | + """Audio x1/x2/x3: N markers + N clips transcribe, splice, and generate. |
| 205 | +
|
| 206 | + Exercises the multi-clip long-audio serving path through the ASR processor |
| 207 | + running inside vLLM's engine subprocess. Content is not asserted (WER is a |
| 208 | + separate #47 box); the bar is a completed request with well-formed output. |
| 209 | + """ |
| 210 | + from vllm import SamplingParams |
| 211 | + |
| 212 | + ceiling = int(getattr(served["config"], "asr_max_audio_clips", 32) or 32) |
| 213 | + if num_clips > ceiling: |
| 214 | + pytest.skip(f"checkpoint clip ceiling {ceiling} < {num_clips}") |
| 215 | + |
| 216 | + marker = "<|audio|>" |
| 217 | + prompt = { |
| 218 | + "prompt": marker * num_clips + " What was said?", |
| 219 | + "multi_modal_data": { |
| 220 | + "audio": [ |
| 221 | + (_tone(freq=220.0 * (i + 1)), _TARGET_SR) for i in range(num_clips) |
| 222 | + ] |
| 223 | + }, |
| 224 | + } |
| 225 | + outputs = served["llm"].generate( |
| 226 | + prompt, SamplingParams(max_tokens=8, temperature=0.0) |
| 227 | + ) |
| 228 | + _one_completion(outputs) |
0 commit comments