Skip to content

Commit 51b9be6

Browse files
committed
tests: add e2e vLLM audio serving smoke (text + adapter + audio x1/x2/x3)
CI-runnable serving smoke closing the #47 e2e box. Boots a real composed, audio-enabled GraniteSwitch checkpoint under vLLM and drives text-only, adapter-control-token, and audio x1/x2/x3 requests through one live engine. Markers slow+requires_model+gpu (opt-in via -m); model built through the compose CLI with --enable-audio; synthetic audio keeps it asset-free. Verified green on an A100 with ibm-granite/granite-4.1-3b + granitelib-core-r1.0: 5 passed. Signed-off-by: BAR HAIM <barha@il.ibm.com>
1 parent 2e5d1fc commit 51b9be6

1 file changed

Lines changed: 228 additions & 0 deletions

File tree

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
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

Comments
 (0)