Skip to content

Commit 4e23bff

Browse files
yenuo26wangyu31577
andauthored
[Test] Add full test for Qwen3-Omni-30B-A3B-Instruct (vllm-project#720)
Signed-off-by: wangyu31577 <wangyu31577@hundsun.com> Co-authored-by: wangyu31577 <wangyu31577@hundsun.com>
1 parent 3fb6adc commit 4e23bff

File tree

5 files changed

+555
-0
lines changed

5 files changed

+555
-0
lines changed

docker/Dockerfile.ci

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ WORKDIR ${APP_DIR}
66

77
COPY . .
88

9+
# Install system dependencies
10+
RUN apt-get update && \
11+
apt-get install -y ffmpeg && \
12+
apt-get clean && \
13+
rm -rf /var/lib/apt/lists/*
14+
915
# Install vllm-omni into the same uv-managed Python environment used by the base image.
1016
RUN uv pip install --python "$(python3 -c 'import sys; print(sys.executable)')" --no-cache-dir ".[dev]"
1117

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ dev = [
5050
"pytest-cov>=4.0.0",
5151
"mypy==1.11.1",
5252
"pre-commit==4.0.1",
53+
"openai-whisper>=20250625",
54+
"psutil>=7.2.0"
5355
]
5456

5557
docs = [

tests/conftest.py

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
1+
import base64
12
import os
3+
import socket
4+
import subprocess
5+
import sys
6+
import time
7+
from pathlib import Path
8+
from typing import Any
29

10+
import psutil
311
import pytest
412
import torch
13+
import whisper
14+
import yaml
515
from vllm.logger import init_logger
16+
from vllm.utils import get_open_port
617

718
logger = init_logger(__name__)
819

@@ -34,3 +45,286 @@ def clean_gpu_memory_between_tests():
3445
if torch.cuda.is_available():
3546
torch.cuda.empty_cache()
3647
gc.collect()
48+
49+
50+
def dummy_messages_from_mix_data(
51+
system_prompt: dict[str, Any] = None,
52+
video_data_url: Any = None,
53+
audio_data_url: Any = None,
54+
image_data_url: Any = None,
55+
content_text: str = None,
56+
):
57+
"""Create messages with video、image、audio data URL for OpenAI API."""
58+
59+
if content_text is not None:
60+
content = [{"type": "text", "text": content_text}]
61+
else:
62+
content = []
63+
64+
media_items = []
65+
if isinstance(video_data_url, list):
66+
for video_url in video_data_url:
67+
media_items.append((video_url, "video"))
68+
else:
69+
media_items.append((video_data_url, "video"))
70+
71+
if isinstance(image_data_url, list):
72+
for url in image_data_url:
73+
media_items.append((url, "image"))
74+
else:
75+
media_items.append((image_data_url, "image"))
76+
77+
if isinstance(audio_data_url, list):
78+
for url in audio_data_url:
79+
media_items.append((url, "audio"))
80+
else:
81+
media_items.append((audio_data_url, "audio"))
82+
83+
content.extend(
84+
{"type": f"{media_type}_url", f"{media_type}_url": {"url": url}}
85+
for url, media_type in media_items
86+
if url is not None
87+
)
88+
messages = [{"role": "user", "content": content}]
89+
if system_prompt is not None:
90+
messages = [system_prompt] + messages
91+
return messages
92+
93+
94+
def cosine_similarity_text(s1, s2):
95+
"""
96+
Calculate cosine similarity between two text strings.
97+
Notes:
98+
------
99+
- Higher score means more similar texts
100+
- Score of 1.0 means identical word composition (bag-of-words)
101+
- Score of 0.0 means completely different vocabulary
102+
"""
103+
from sklearn.feature_extraction.text import CountVectorizer
104+
from sklearn.metrics.pairwise import cosine_similarity
105+
106+
vectorizer = CountVectorizer().fit_transform([s1, s2])
107+
vectors = vectorizer.toarray()
108+
return cosine_similarity([vectors[0]], [vectors[1]])[0][0]
109+
110+
111+
def convert_audio_to_text(audio_data):
112+
"""
113+
Convert base64 encoded audio data to text using speech recognition.
114+
"""
115+
116+
audio_data = base64.b64decode(audio_data)
117+
output_path = f"./test_{int(time.time())}"
118+
with open(output_path, "wb") as audio_file:
119+
audio_file.write(audio_data)
120+
121+
print(f"audio data is saved: {output_path}")
122+
model = whisper.load_model("base")
123+
text = model.transcribe(output_path)["text"]
124+
if text:
125+
return text
126+
else:
127+
return ""
128+
129+
130+
def modify_stage_config(
131+
yaml_path: str,
132+
stage_updates: dict[int, dict[str, Any]],
133+
) -> str:
134+
"""
135+
Batch modify configurations for multiple stages in a YAML file.
136+
137+
Args:
138+
yaml_path: Path to the YAML configuration file.
139+
stage_updates: Dictionary where keys are stage IDs and values are dictionaries of
140+
modifications for that stage. Each modification dictionary uses
141+
dot-separated paths as keys and new configuration values as values.
142+
Example: {
143+
0: {'engine_args.max_model_len': 5800},
144+
1: {'runtime.max_batch_size': 2}
145+
}
146+
147+
Returns:
148+
str: Path to the newly created modified YAML file with timestamp suffix.
149+
150+
Example:
151+
>>> output_file = modify_stage_config(
152+
... 'config.yaml',
153+
... {
154+
... 0: {'engine_args.max_model_len': 5800},
155+
... 1: {'runtime.max_batch_size': 2}
156+
... }
157+
... )
158+
>>> print(f"Modified configuration saved to: {output_file}")
159+
Modified configuration saved to: config_1698765432.yaml
160+
"""
161+
path = Path(yaml_path)
162+
if not path.exists():
163+
raise FileNotFoundError(f"yaml does not exist: {path}")
164+
try:
165+
with open(yaml_path, encoding="utf-8") as f:
166+
config = yaml.safe_load(f) or {}
167+
except Exception as e:
168+
raise ValueError(f"Cannot parse YAML file: {e}")
169+
170+
stage_args = config.get("stage_args", [])
171+
if not stage_args:
172+
raise ValueError("the stage_args does not exist")
173+
174+
for stage_id, config_dict in stage_updates.items():
175+
target_stage = None
176+
for stage in stage_args:
177+
if stage.get("stage_id") == stage_id:
178+
target_stage = stage
179+
break
180+
181+
if target_stage is None:
182+
available_ids = [s.get("stage_id") for s in stage_args if "stage_id" in s]
183+
raise KeyError(f"Stage ID {stage_id} is not exist, available IDs: {available_ids}")
184+
185+
for key_path, value in config_dict.items():
186+
current = target_stage
187+
keys = key_path.split(".")
188+
for i in range(len(keys) - 1):
189+
key = keys[i]
190+
if key not in current:
191+
raise KeyError(f"the {'.'.join(keys[: i + 1])} does not exist")
192+
193+
elif not isinstance(current[key], dict) and i < len(keys) - 2:
194+
raise ValueError(f"{'.'.join(keys[: i + 1])}' cannot continue deeper because it's not a dict")
195+
current = current[key]
196+
current[keys[-1]] = value
197+
198+
output_path = f"{yaml_path.split('.')[0]}_{int(time.time())}.yaml"
199+
with open(output_path, "w", encoding="utf-8") as f:
200+
yaml.dump(config, f, default_flow_style=False, sort_keys=False, allow_unicode=True, indent=2)
201+
202+
return output_path
203+
204+
205+
class OmniServer:
206+
"""Omniserver for vLLM-Omni tests."""
207+
208+
def __init__(
209+
self,
210+
model: str,
211+
serve_args: list[str],
212+
*,
213+
env_dict: dict[str, str] | None = None,
214+
) -> None:
215+
self.model = model
216+
self.serve_args = serve_args
217+
self.env_dict = env_dict
218+
self.proc: subprocess.Popen | None = None
219+
self.host = "127.0.0.1"
220+
self.port = get_open_port()
221+
222+
def _start_server(self) -> None:
223+
"""Start the vLLM-Omni server subprocess."""
224+
env = os.environ.copy()
225+
env["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
226+
if self.env_dict is not None:
227+
env.update(self.env_dict)
228+
229+
cmd = [
230+
sys.executable,
231+
"-m",
232+
"vllm_omni.entrypoints.cli.main",
233+
"serve",
234+
self.model,
235+
"--omni",
236+
"--host",
237+
self.host,
238+
"--port",
239+
str(self.port),
240+
] + self.serve_args
241+
242+
print(f"Launching OmniServer with: {' '.join(cmd)}")
243+
self.proc = subprocess.Popen(
244+
cmd,
245+
env=env,
246+
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), # Set working directory to vllm-omni root
247+
)
248+
249+
# Wait for server to be ready
250+
max_wait = 600 # 10 minutes
251+
start_time = time.time()
252+
while time.time() - start_time < max_wait:
253+
try:
254+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
255+
sock.settimeout(1)
256+
result = sock.connect_ex((self.host, self.port))
257+
if result == 0:
258+
print(f"Server ready on {self.host}:{self.port}")
259+
return
260+
except Exception:
261+
pass
262+
time.sleep(2)
263+
264+
raise RuntimeError(f"Server failed to start within {max_wait} seconds")
265+
266+
def _kill_process_tree(self, pid):
267+
"""kill process and its children"""
268+
try:
269+
parent = psutil.Process(pid)
270+
children = parent.children(recursive=True)
271+
for child in children:
272+
try:
273+
child.terminate()
274+
except psutil.NoSuchProcess:
275+
pass
276+
277+
gone, still_alive = psutil.wait_procs(children, timeout=10)
278+
279+
for child in still_alive:
280+
try:
281+
child.kill()
282+
except psutil.NoSuchProcess:
283+
pass
284+
285+
try:
286+
parent.terminate()
287+
parent.wait(timeout=10)
288+
except (psutil.NoSuchProcess, psutil.TimeoutExpired):
289+
try:
290+
parent.kill()
291+
except psutil.NoSuchProcess:
292+
pass
293+
294+
except psutil.NoSuchProcess:
295+
pass
296+
297+
def __enter__(self):
298+
self._start_server()
299+
return self
300+
301+
def __exit__(self, exc_type, exc_val, exc_tb):
302+
if self.proc:
303+
try:
304+
parent = psutil.Process(self.proc.pid)
305+
children = parent.children(recursive=True)
306+
for child in children:
307+
try:
308+
child.terminate()
309+
except psutil.NoSuchProcess:
310+
pass
311+
312+
gone, still_alive = psutil.wait_procs(children, timeout=10)
313+
314+
for child in still_alive:
315+
try:
316+
child.kill()
317+
except psutil.NoSuchProcess:
318+
pass
319+
320+
try:
321+
parent.terminate()
322+
parent.wait(timeout=10)
323+
except (psutil.NoSuchProcess, psutil.TimeoutExpired):
324+
try:
325+
parent.kill()
326+
except psutil.NoSuchProcess:
327+
pass
328+
329+
except psutil.NoSuchProcess:
330+
pass

0 commit comments

Comments
 (0)