test_whisper.py 11.4 KB
Newer Older
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3

4
5
6
7
from collections.abc import Sequence
from typing import Any

import librosa
8
import pytest
9
from transformers import AutoModelForSpeechSeq2Seq
10
11

from vllm.assets.audio import AudioAsset
12
from vllm.platforms import current_platform
13

14
from ....conftest import HfRunner, PromptAudioInput, VllmRunner
15
from ....utils import create_new_process_for_each_test, multi_gpu_test
16
17
18
19
20
21
22
from ...registry import HF_EXAMPLE_MODELS
from ...utils import check_logprobs_close

VLLM_PROMPT = "<|startoftranscript|><|en|><|transcribe|><|notimestamps|>"
HF_PROMPT = ""
# Whisper expects 16kHz audio
WHISPER_SAMPLE_RATE = 16000
23

24
25
26
27
28

@pytest.fixture(autouse=True)
def use_spawn_for_whisper(monkeypatch):
    """Whisper has issues with forked workers, use spawn instead."""
    monkeypatch.setenv("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
29
30
31


def run_test(
32
    hf_runner: type[HfRunner],
33
    vllm_runner: type[VllmRunner],
34
    inputs: Sequence[tuple[list[str], list[str], PromptAudioInput]],
35
36
    model: str,
    *,
37
38
39
40
    max_model_len: int,
    dtype: str,
    max_tokens: int,
    num_logprobs: int,
41
    tensor_parallel_size: int,
42
    distributed_executor_backend: str | None = None,
43
    enforce_eager: bool = True,
44
) -> None:
45
    """Inference result should be the same between hf and vllm.
46

47
48
49
50
51
    All the audio fixtures for the test are from AudioAsset.
    For huggingface runner, we provide the audio as input.
    For vllm runner, we provide MultiModalDataDict objects
    and corresponding MultiModalConfig as input.
    """
52
    with vllm_runner(
53
        model,
54
        dtype=dtype,
55
        max_model_len=max_model_len,
56
57
        tensor_parallel_size=tensor_parallel_size,
        distributed_executor_backend=distributed_executor_backend,
58
59
60
        limit_mm_per_prompt={"audio": 2},
        enforce_eager=enforce_eager,
        disable_custom_all_reduce=True,
61
    ) as vllm_model:
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
        vllm_outputs_per_case = [
            vllm_model.generate_greedy_logprobs(
                vllm_prompts,
                max_tokens,
                num_logprobs=num_logprobs,
                audios=audios,
            )
            for vllm_prompts, _, audios in inputs
        ]

    with hf_runner(model, dtype=dtype, auto_cls=AutoModelForSpeechSeq2Seq) as hf_model:
        hf_outputs_per_case = [
            hf_model.generate_greedy_logprobs_limit(
                hf_prompts,
                max_tokens,
                num_logprobs=num_logprobs,
                audios=audios,
            )
            for _, hf_prompts, audios in inputs
        ]

    for hf_outputs, vllm_outputs in zip(hf_outputs_per_case, vllm_outputs_per_case):
        check_logprobs_close(
            outputs_0_lst=hf_outputs,
            outputs_1_lst=vllm_outputs,
            name_0="hf",
            name_1="vllm",
89
        )
90
91


92
@pytest.fixture
93
def resampled_assets() -> list[tuple[Any, int]]:
94
    audio_assets = [AudioAsset("mary_had_lamb"), AudioAsset("winning_call")]
95
    sampled_assets = []
96
97
98
99
100
101
102
    for asset in audio_assets:
        audio, orig_sr = asset.audio_and_sample_rate
        # Resample to Whisper's expected sample rate (16kHz)
        if orig_sr != WHISPER_SAMPLE_RATE:
            audio = librosa.resample(
                audio, orig_sr=orig_sr, target_sr=WHISPER_SAMPLE_RATE
            )
103
104
105
106
107
108
109
110
111
112
113
114
115
        sampled_assets.append(
            (audio, WHISPER_SAMPLE_RATE),
        )
    return sampled_assets


@pytest.fixture
def input_audios(
    resampled_assets,
) -> list[tuple[list[str], list[str], list[tuple[Any, int]]]]:
    inputs = []
    # audio assets are resampled to WHISPER_SAMPLE_RATE
    for audio_info in resampled_assets:
116
        # vLLM prompts, HF prompts, audio inputs
117
        inputs.append(([VLLM_PROMPT], [HF_PROMPT], [audio_info]))
118
    return inputs
119
120


121
122
123
124
def check_model_available(model: str) -> None:
    model_info = HF_EXAMPLE_MODELS.find_hf_info(model)
    model_info.check_available_online(on_fail="skip")
    model_info.check_transformers_version(on_fail="skip")
125
126


127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
@pytest.mark.parametrize("dtype", ["half"])
@pytest.mark.parametrize("max_tokens", [64])
@pytest.mark.parametrize("beam_width", [1, 2])
def test_beam_search_encoder_decoder(
    monkeypatch,
    hf_runner,
    vllm_runner,
    dtype: str,
    max_tokens: int,
    beam_width: int,
    resampled_assets,
) -> None:
    """Test beam search with encoder-decoder models (Whisper)."""
    if current_platform.is_rocm():
        monkeypatch.setenv("VLLM_ROCM_USE_SKINNY_GEMM", "0")

    model = "openai/whisper-large-v3-turbo"
    check_model_available(model)

    hf_prompts = [
        "<|startoftranscript|>",
        "<|startoftranscript|>",
    ]

    with hf_runner(model, dtype=dtype, auto_cls=AutoModelForSpeechSeq2Seq) as hf_model:
        hf_outputs = hf_model.generate_beam_search(
            hf_prompts,
            beam_width=beam_width,
            max_tokens=max_tokens,
            audios=resampled_assets,
        )

    # Test both explicit encoder/decoder prompts
    vllm_prompts = [
        # Implicit encoder/decoder prompt
        {
            "prompt": "<|startoftranscript|>",
            "multi_modal_data": {"audio": resampled_assets[0]},
        },
        # Explicit encoder/decover prompt
        {
            "encoder_prompt": {
                "prompt": "",
                "multi_modal_data": {"audio": resampled_assets[1]},
            },
            "decoder_prompt": "<|startoftranscript|>",
        },
    ]

    with vllm_runner(
        model,
        dtype="half",
        max_model_len=448,
        tensor_parallel_size=1,
        max_num_seqs=4,
        limit_mm_per_prompt={"audio": 2},
        enforce_eager=True,
    ) as vllm_model:
        vllm_outputs = vllm_model.generate_beam_search(
            vllm_prompts,
            beam_width=beam_width,
            max_tokens=max_tokens,
        )

    for i in range(len(vllm_prompts)):
        hf_output_ids, hf_output_texts = hf_outputs[i]
        vllm_output_ids, vllm_output_texts = vllm_outputs[i]

        for j, (hf_text, vllm_text) in enumerate(
            zip(hf_output_texts, vllm_output_texts)
        ):
            print(f">>>{j}-th hf output [NOTE: special tokens are filtered]:")
            print(hf_text)
            print(f">>>{j}-th vllm output:")
            print(vllm_text)

        # Check that we got the same number of beams
        assert len(hf_output_ids) == len(vllm_output_ids)

        # For encoder-decoder models, we primarily want to verify that:
        # 1. Beam search completes without errors
        # 2. We get the expected number of beams
        # 3. Outputs are reasonable (non-empty, diverse beams)
        for j in range(len(vllm_output_ids)):
            # Check that outputs are not empty
            assert len(vllm_output_ids[j]) > 0, f"Prompt {i}, beam {j}: empty output"
            # Check that decoded text is not empty
            assert len(vllm_output_texts[j].strip()) > 0, (
                f"Prompt {i}, beam {j}: empty text output"
            )


219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
def test_parse_language_detection_output():
    """Unit test for WhisperForConditionalGeneration.parse_language_detection_output.

    No GPU or model loading required.
    """
    from unittest.mock import MagicMock

    from vllm.model_executor.models.whisper import (
        WhisperForConditionalGeneration,
    )

    cls = WhisperForConditionalGeneration

    def make_tokenizer(return_value: str) -> MagicMock:
        tok = MagicMock()
        tok.decode = MagicMock(return_value=return_value)
        return tok

    # English
    assert (
        cls.parse_language_detection_output([50259], make_tokenizer("<|en|>")) == "en"
    )

    # German
    assert (
        cls.parse_language_detection_output([50261], make_tokenizer("<|de|>")) == "de"
    )

    # Unsupported language code
    with pytest.raises(AssertionError):
        cls.parse_language_detection_output([99999], make_tokenizer("<|xx|>"))

    # No special token format
    with pytest.raises(AssertionError):
        cls.parse_language_detection_output([1], make_tokenizer("hello"))

    # Empty token_ids
    with pytest.raises((AssertionError, IndexError)):
        cls.parse_language_detection_output([], make_tokenizer("anything"))


260
@pytest.mark.core_model
261
262
@pytest.mark.cpu_model
@pytest.mark.parametrize("model", ["openai/whisper-large-v3-turbo"])
263
@pytest.mark.parametrize("dtype", ["half", "float"])
264
265
266
267
268
269
270
271
272
273
274
275
276
277
@pytest.mark.parametrize("num_logprobs", [5])
@pytest.mark.parametrize("enforce_eager", [True, False])
def test_models(
    hf_runner,
    vllm_runner,
    model: str,
    dtype: str,
    num_logprobs: int,
    input_audios,
    enforce_eager: bool,
) -> None:
    check_model_available(model)
    if current_platform.is_cpu() and not enforce_eager:
        pytest.skip("Skipping test for CPU with non-eager mode")
278
    run_test(
279
        hf_runner,
280
        vllm_runner,
281
        input_audios,
282
283
        model,
        dtype=dtype,
284
285
286
287
288
        max_model_len=448,
        max_tokens=200,
        num_logprobs=num_logprobs,
        tensor_parallel_size=1,
        enforce_eager=enforce_eager,
289
    )
290
291
292
293
294
295


@multi_gpu_test(num_gpus=2)
@pytest.mark.core_model
@pytest.mark.parametrize("model", ["openai/whisper-large-v3-turbo"])
@pytest.mark.parametrize("distributed_executor_backend", ["ray", "mp"])
296
297
298
299
@pytest.mark.parametrize("dtype", ["half"])
@pytest.mark.parametrize("max_tokens", [200])
@pytest.mark.parametrize("num_logprobs", [5])
@create_new_process_for_each_test("spawn")
300
def test_models_distributed(
301
    hf_runner,
302
    vllm_runner,
303
304
305
306
307
308
    model: str,
    distributed_executor_backend: str,
    dtype: str,
    max_tokens: int,
    num_logprobs: int,
    input_audios,
309
) -> None:
310
    check_model_available(model)
311
    run_test(
312
        hf_runner,
313
        vllm_runner,
314
        input_audios,
315
        model,
316
317
318
319
        dtype=dtype,
        max_model_len=448,
        max_tokens=max_tokens,
        num_logprobs=num_logprobs,
320
321
        tensor_parallel_size=2,
        distributed_executor_backend=distributed_executor_backend,
322
        enforce_eager=False,
323
    )
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366


@pytest.mark.core_model
@pytest.mark.parametrize("model", ["openai/whisper-large-v3-turbo"])
def test_encoder_cache_cleanup(
    vllm_runner,
    model: str,
    input_audios,
    monkeypatch,
) -> None:
    """Test that encoder cache is properly cleaned up after requests complete.

    This is a regression test for a bug where encoder cache entries were freed
    in the same scheduling step they were allocated, before the model could use
    them.
    """
    # Set single-process mode to access the model runner's encoder cache directly
    monkeypatch.setenv("VLLM_ENABLE_V1_MULTIPROCESSING", "0")
    check_model_available(model)

    with vllm_runner(
        model,
        dtype="half",
        max_model_len=448,
        tensor_parallel_size=1,
        limit_mm_per_prompt={"audio": 2},
        enforce_eager=True,
    ) as vllm_model:
        engine_core = vllm_model.llm.llm_engine.engine_core.engine_core
        model_runner = engine_core.model_executor.driver_worker.worker.model_runner
        encoder_cache = model_runner.encoder_cache

        # Run multiple sequential requests to ensure cache is properly managed
        for vllm_prompts, _, audios in input_audios:
            vllm_model.generate_greedy(vllm_prompts, max_tokens=50, audios=audios)

        # After all requests complete, encoder cache should be empty
        cache_size = len(encoder_cache)
        assert cache_size == 0, (
            f"Encoder cache should be empty after all requests complete, "
            f"but has {cache_size} entries. This indicates encoder cache "
            f"entries are not being properly freed."
        )