A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 8, 08:45 UTC
[Voxtral Realtime] Refactor & Improve buffering logic (#34428)
Signed-off-by: Patrick von Platen <patrick.v.platen@gmail.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Patrick von Platen committed
Feb 12, 2026 at 18:46 UTC
6c0baee61025f258c6d56830d0150feab34c45ab
7 files changed
+135
-238
requirements/common.txt
+2
-2
@@ -31,7 +31,7 @@ partial-json-parser # used for parsing partial JSON outputs
31
pyzmq >= 25.0.0
32
msgspec
33
gguf >= 0.17.0
34
-mistral_common[image] >= 1.9.0
34
+mistral_common[image] >= 1.9.1
35
opencv-python-headless >= 4.13.0 # required for video IO
36
pyyaml
37
six>=1.16.0; python_version > '3.11' # transitive dependency of pandas that needs to be the latest version for python 3.12
@@ -52,4 +52,4 @@ anthropic >= 0.71.0
52
model-hosting-container-standards >= 0.1.13, < 1.0.0
53
mcp
54
grpcio
55
-grpcio-reflection
\ No newline at end of file
55
+grpcio-reflection
requirements/nightly_torch_test.txt
+1
-1
@@ -23,7 +23,7 @@ jiwer # required for audio tests
23
timm # required for internvl test
24
transformers_stream_generator # required for qwen-vl test
25
matplotlib # required for qwen-vl test
26
-mistral_common[image,audio] >= 1.9.0 # required for voxtral test
26
+mistral_common[image,audio] >= 1.9.1 # required for voxtral test
27
num2words # required for smolvlm test
28
opencv-python-headless >= 4.13.0 # required for video test
29
datamodel_code_generator # required for minicpm3 test
requirements/test.in
+1
-1
@@ -30,7 +30,7 @@ torchaudio==2.10.0
30
torchvision==0.25.0
31
transformers_stream_generator # required for qwen-vl test
32
matplotlib # required for qwen-vl test
33
-mistral_common[image,audio] >= 1.9.0 # required for voxtral test
33
+mistral_common[image,audio] >= 1.9.1 # required for voxtral test
34
num2words # required for smolvlm test
35
open_clip_torch==2.32.0 # Required for nemotron_vl test, Nemotron Parse in test_common.py
36
opencv-python-headless >= 4.13.0 # required for video test
requirements/test.txt
+1
-1
@@ -499,7 +499,7 @@ mbstrdecoder==1.1.3
499
# typepy
500
mdurl==0.1.2
501
# via markdown-it-py
502
-mistral-common==1.9.0
502
+mistral-common==1.9.1
503
# via -r requirements/test.in
504
mlflow==2.22.0
505
# via terratorch
tests/models/multimodal/generation/test_voxtral_realtime.py
+15
-113
@@ -1,6 +1,5 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-import asyncio
3
from dataclasses import asdict
4
5
import pytest
@@ -10,14 +9,13 @@ from mistral_common.protocol.transcription.request import (
9
StreamingMode,
10
TranscriptionRequest,
11
)
13
-from mistral_common.tokens.tokenizers.audio import AudioConfig
12
from mistral_common.tokens.tokenizers.mistral import MistralTokenizer
13
14
from vllm import LLM, EngineArgs, SamplingParams
15
from vllm.assets.audio import AudioAsset
16
from vllm.engine.arg_utils import AsyncEngineArgs
19
-from vllm.inputs.data import TokensPrompt
20
-from vllm.v1.engine.async_llm import AsyncLLM, StreamingInput
17
+from vllm.model_executor.models.voxtral_realtime import VoxtralRealtimeBuffer
18
+from vllm.v1.engine.async_llm import AsyncLLM
19
20
MODEL_NAME = "mistralai/Voxtral-Mini-4B-Realtime-2602"
21
ENGINE_CONFIG = dict(
@@ -114,136 +112,40 @@ def test_voxtral_realtime_forward(audio_assets, tokenizer, engine):
112
assert texts == EXPECTED_TEXT
113
114
117
-class RealTimeAudioInput:
118
- """
119
- This class is used to stream an audio file just as
120
- if it would be streamed in real-time.
121
- """
122
-
123
- def __init__(self, tokenizer: MistralTokenizer) -> None:
124
- self._tokenizer = tokenizer
125
- self._config: AudioConfig = (
126
- self._tokenizer.instruct_tokenizer.audio_encoder.audio_config
127
- )
128
-
129
- self._look_ahead_in_ms = self._config.streaming_look_ahead_ms
130
- self._look_back_in_ms = self._config.streaming_look_back_ms
131
-
132
- self._sampling_rate = self._config.sampling_rate
133
-
134
- self._audio: Audio | None = None
135
-
136
- # mutable objects
137
- self._start = 0
138
-
139
- n_left_pad_samples = (
140
- self._config.raw_audio_length_per_tok * self._config.n_left_pad_tokens
141
- )
142
- self._end = self.streaming_delay + n_left_pad_samples + self.streaming_size
143
- self._queue: asyncio.Queue[StreamingInput | None] = asyncio.Queue()
144
-
145
- @classmethod
146
- async def create(cls, audio: Audio, tokenizer: MistralTokenizer):
147
- self = cls(tokenizer)
148
-
149
- # we're doing "OFFLINE" encoding here to right & left pad the audio since
150
- # we have access to the whole audio
151
- # if we'd do an actual online realtime streaming application we
152
- # should instead pass `StreamingMode.ONLINE`
153
- req = TranscriptionRequest(
154
- streaming=StreamingMode.OFFLINE,
155
- audio=RawAudio.from_audio(audio),
156
- language=None,
157
- )
158
- audio_enc = self._tokenizer.encode_transcription(req)
159
- self._audio = audio_enc.audios[0]
160
-
161
- # add first request
162
- await self.add_tokens(audio_enc.tokens)
163
-
164
- return self
165
-
166
- @property
167
- def look_ahead(self) -> int:
168
- return self._get_len_in_samples(self._look_ahead_in_ms)
169
-
170
- @property
171
- def look_back(self) -> int:
172
- return self._get_len_in_samples(self._look_back_in_ms)
173
-
174
- @property
175
- def streaming_delay(self) -> int:
176
- return self._get_len_in_samples(self._config.transcription_delay_ms)
177
-
178
- @property
179
- def streaming_size(self) -> int:
180
- stream_size_in_ms = 1000 / self._config.frame_rate
181
- return self._get_len_in_samples(stream_size_in_ms)
182
-
183
- def _get_len_in_samples(self, len_in_ms: float) -> int:
184
- _len_in_s = self._sampling_rate * len_in_ms / 1000
185
- assert _len_in_s.is_integer(), _len_in_s
186
- len_in_s = int(_len_in_s)
187
-
188
- return len_in_s
189
-
190
- async def add_tokens(self, tokens: list[int]) -> None:
191
- assert self._audio is not None
192
- if self._start >= len(self._audio.audio_array):
193
- self.stop()
194
- return
195
-
196
- _end = self._end + self.look_ahead
197
- _start = max(0, self._start - self.look_back)
198
-
199
- multi_modal_data = {"audio": (self._audio.audio_array[_start:_end], None)}
200
-
201
- prompt = TokensPrompt(
202
- prompt_token_ids=tokens, multi_modal_data=multi_modal_data
203
- )
204
-
205
- await self._queue.put(StreamingInput(prompt))
206
-
207
- # increase
208
- self._start = self._end
209
- self._end = self._end + self.streaming_size
210
-
211
- def stop(self):
212
- self._queue.put_nowait(None)
213
-
214
- async def generator(self):
215
- while (item := await self._queue.get()) is not None:
216
- yield item
217
-
218
-
115
@pytest.mark.asyncio
116
async def test_voxtral_realtime_generator(audio_assets, tokenizer, async_engine):
117
sampling_params = SamplingParams(temperature=0.0, max_tokens=1)
118
+ audio_config = tokenizer.instruct_tokenizer.audio_encoder.audio_config
119
120
output_tokens_list = []
121
for i, audio_asset in enumerate(audio_assets):
122
output_tokens = []
123
audio = Audio.from_file(audio_asset.get_local_path(), strict=False)
227
- streaming_input = await RealTimeAudioInput.create(
228
- audio=audio, tokenizer=tokenizer
124
+
125
+ req = TranscriptionRequest(
126
+ streaming=StreamingMode.OFFLINE,
127
+ audio=RawAudio.from_audio(audio),
128
+ language=None,
129
)
130
+ audio_enc = tokenizer.encode_transcription(req)
131
+
132
+ buffer = VoxtralRealtimeBuffer(audio_config, audio_enc.tokens)
133
+ await buffer.append_audio(audio_enc.audios[0].audio_array)
134
+ await buffer.append_audio(None)
135
136
request_id = f"session-{i}"
137
138
async for resp in async_engine.generate(
234
- prompt=streaming_input.generator(),
139
+ prompt=buffer.get_input_stream(),
140
sampling_params=sampling_params,
141
request_id=request_id,
142
):
143
tokens = resp.outputs[0].token_ids[-1:]
239
-
144
output_tokens.extend(tokens)
241
- await streaming_input.add_tokens(tokens)
145
+ await buffer.append_tokens(tokens)
146
147
output_tokens_list.append(output_tokens)
148
149
texts = [tokenizer.decode(output_tokens) for output_tokens in output_tokens_list]
246
-
150
texts[1] = texts[1].replace("a base hit", "OBS").replace("oh my", "oh, my")
248
-
151
assert texts == EXPECTED_TEXT
vllm/model_executor/models/voxtral.py
+1
-3
@@ -155,9 +155,7 @@ class VoxtralProcessorAdapter:
155
assert audio.ndim == 1
156
157
if not self._audio_processor.audio_config.is_streaming:
158
- audio = self._audio_processor.pad(
159
- audio, self.sampling_rate, is_online_streaming=False
160
- )
158
+ audio = self._audio_processor.pad(audio, self.sampling_rate)
159
160
audio_tokens = [self.begin_audio_token_id] + [
161
self.audio_token_id
vllm/model_executor/models/voxtral_realtime.py
+114
-117
@@ -3,7 +3,7 @@
3
4
import asyncio
5
import math
6
-from collections.abc import AsyncGenerator, Mapping
6
+from collections.abc import AsyncGenerator, Iterable, Iterator, Mapping
7
from typing import Literal
8
9
import numpy as np
@@ -18,7 +18,7 @@ from mistral_common.tokens.tokenizers.audio import Audio, AudioConfig
18
from vllm.compilation.decorators import support_torch_compile
19
from vllm.config import ModelConfig, SpeechToTextConfig, VllmConfig
20
from vllm.envs import VLLM_ENGINE_ITERATION_TIMEOUT_S
21
-from vllm.inputs.data import PromptType, TokensPrompt
21
+from vllm.inputs.data import PromptType, StreamingInput, TokensPrompt
22
from vllm.logger import init_logger
23
from vllm.model_executor.models.interfaces import MultiModalEmbeddings, SupportsRealtime
24
from vllm.model_executor.models.voxtral import (
@@ -47,8 +47,6 @@ from .utils import (
47
48
logger = init_logger(__name__)
49
50
-_PRE_ALLOCATE_BUFFER_SIZE_IN_S = 30
51
-
50
51
class VoxtralRealtimeMultiModalProcessor(VoxtralMultiModalProcessor):
52
def __init__(
@@ -130,84 +128,81 @@ def _expand_tensor(input_tensor: torch.Tensor, scaling: int) -> torch.Tensor:
128
129
130
class VoxtralRealtimeBuffer:
133
- def __init__(self, config: AudioConfig) -> None:
131
+ def __init__(self, config: AudioConfig, prompt_tokens: list[int]) -> None:
132
self._config = config
133
136
- self._look_ahead_in_ms = config.streaming_look_ahead_ms
137
- self._look_back_in_ms = config.streaming_look_back_ms
138
-
139
- self._sampling_rate = self._config.sampling_rate
140
-
141
- self._look_ahead = self._get_len_in_samples(self._look_ahead_in_ms)
142
- self._look_back = self._get_len_in_samples(self._look_back_in_ms)
143
- self._streaming_size = self._get_len_in_samples(1000 / self._config.frame_rate)
144
-
145
- # mutable objects
146
- streaming_delay = self._get_len_in_samples(self._config.transcription_delay_ms)
147
- self._start = 0
148
- self._end = streaming_delay + self._streaming_size
149
-
150
- # always pre-allocate 30 second buffers
151
- self._buffer_size = _PRE_ALLOCATE_BUFFER_SIZE_IN_S * self._sampling_rate
152
- self._buffer: np.ndarray = np.empty(self._buffer_size, dtype=np.float32)
153
- self._filled_buffer_len = 0
154
-
155
- @property
156
- def start_idx(self):
157
- return max(self._start - self._look_back, 0)
158
-
159
- @property
160
- def end_idx(self):
161
- return self._end + self._look_ahead
162
-
163
- @property
164
- def is_audio_complete(self) -> bool:
165
- return self._filled_buffer_len >= self.end_idx
166
-
167
- def _get_len_in_samples(self, len_in_ms: float) -> int:
168
- _len_in_s = self._sampling_rate * len_in_ms / 1000
169
- assert _len_in_s.is_integer(), _len_in_s
170
- len_in_s = int(_len_in_s)
171
-
172
- return len_in_s
173
-
174
- def _allocate_new_buffer(self) -> None:
175
- # allocate new buffer
176
- new_buffer = np.empty(self._buffer_size, dtype=np.float32)
177
- left_to_copy = max(self._filled_buffer_len - self.start_idx, 0)
178
-
179
- if left_to_copy > 0:
180
- new_buffer[:left_to_copy] = self._buffer[
181
- self.start_idx : self._filled_buffer_len
182
- ]
183
-
184
- del self._buffer
185
- self._buffer = new_buffer
186
-
187
- self._filled_buffer_len = left_to_copy
188
- self._start = self._look_back
189
- self._end = self._start + self._streaming_size
190
-
191
- def write_audio(self, audio: np.ndarray) -> None:
192
- put_end_idx = self._filled_buffer_len + len(audio)
193
-
194
- if put_end_idx > self._buffer_size:
195
- self._allocate_new_buffer()
196
-
197
- self._buffer[self._filled_buffer_len : self._filled_buffer_len + len(audio)] = (
198
- audio
199
- )
200
- self._filled_buffer_len += len(audio)
201
-
202
- def read_audio(self) -> np.ndarray | None:
203
- if not self.is_audio_complete:
204
- return None
134
+ _look_ahead_in_ms = self._config.streaming_look_ahead_ms
135
+ _look_back_in_ms = self._config.streaming_look_back_ms
136
+ self._look_ahead_in_samples = self._ms_to_samples(_look_ahead_in_ms)
137
+ self._look_back_in_samples = self._ms_to_samples(_look_back_in_ms)
138
+
139
+ # None signals the end
140
+ self._audio_queue: asyncio.Queue[np.ndarray | None] = asyncio.Queue()
141
+ self._leftover: np.ndarray | None = None
142
+ self._token_queue: asyncio.Queue[int] = asyncio.Queue()
143
+
144
+ self._initial_end = len(prompt_tokens) * self._config.raw_audio_length_per_tok
145
+ for token in prompt_tokens:
146
+ self._token_queue.put_nowait(token)
147
+
148
+ def _generate_frame_size_and_num_tokens(self) -> Iterator[tuple[int, int]]:
149
+ streaming_step_size = self._ms_to_samples(1000 / self._config.frame_rate)
150
+ start = 0
151
+ end = self._initial_end
152
+ while True:
153
+ frame_start = max(start - self._look_back_in_samples, 0)
154
+ frame_end = end + self._look_ahead_in_samples
155
+ frame_size = frame_end - frame_start
156
+ num_tokens = (end - start) / self._config.raw_audio_length_per_tok
157
+ assert num_tokens.is_integer()
158
+ yield frame_size, int(num_tokens)
159
+ start = end
160
+ end += streaming_step_size
161
+
162
+ def _ms_to_samples(self, ms: float) -> int:
163
+ len_ = self._config.sampling_rate * ms / 1000
164
+ assert len_.is_integer(), len_
165
+ return int(len_)
166
+
167
+ async def append_audio(self, audio_array: np.ndarray | None) -> None:
168
+ await self._audio_queue.put(audio_array)
169
+
170
+ async def append_tokens(self, tokens: Iterable[int]) -> None:
171
+ for token in tokens:
172
+ await self._token_queue.put(token)
173
+
174
+ async def get_input_stream(self) -> AsyncGenerator[StreamingInput]:
175
+ for frame_size, num_tokens in self._generate_frame_size_and_num_tokens():
176
+ next_tokens = [await self._token_queue.get() for _ in range(num_tokens)]
177
+
178
+ audio_arrays: list[np.ndarray] = (
179
+ [self._leftover] if self._leftover is not None else []
180
+ )
181
+ while sum(len(arr) for arr in audio_arrays) < frame_size:
182
+ arr = await self._audio_queue.get()
183
+ if arr is None:
184
+ return
185
+ audio_arrays.append(arr)
186
+
187
+ audio_array = np.concatenate(audio_arrays)
188
+ frame = audio_array[:frame_size]
189
+
190
+ # The current stride took look_ahead_in_samples audio of the next sample
191
+ # In addition the next sample will take look_back_in_samples audio of
192
+ # the current sample => So let's put both of this into the leftover
193
+ stride = (
194
+ frame_size - self._look_ahead_in_samples - self._look_back_in_samples
195
+ )
196
+ assert stride > 0, f"{stride=} must be positive"
197
206
- audio = self._buffer[self.start_idx : self.end_idx]
207
- self._start = self._end
208
- self._end += self._streaming_size
198
+ self._leftover = audio_array[stride:]
199
210
- return audio
200
+ yield StreamingInput(
201
+ TokensPrompt(
202
+ prompt_token_ids=next_tokens,
203
+ multi_modal_data={"audio": (frame, None)},
204
+ )
205
+ )
206
207
208
@MULTIMODAL_REGISTRY.register_processor(
@@ -234,7 +229,7 @@ class VoxtralRealtimeGeneration(VoxtralForConditionalGeneration, SupportsRealtim
229
)
230
231
audio_config = self.tokenizer.instruct.audio_encoder.audio_config
237
- self.n_delay_tokens = audio_config.num_delay_tokens
232
+ self.n_delay_tokens = audio_config.get_num_delay_tokens()
233
234
# for realtime transcription
235
@classmethod
@@ -248,45 +243,47 @@ class VoxtralRealtimeGeneration(VoxtralForConditionalGeneration, SupportsRealtim
243
audio_encoder = tokenizer.instruct.audio_encoder
244
config = audio_encoder.audio_config
245
251
- buffer = VoxtralRealtimeBuffer(config)
252
- is_first_yield = True
253
-
254
- async for audio in audio_stream:
255
- buffer.write_audio(audio)
256
-
257
- while (new_audio := buffer.read_audio()) is not None:
258
- if is_first_yield:
259
- # make sure that input_stream is empty
260
- assert input_stream.empty()
261
-
262
- audio = Audio(new_audio, config.sampling_rate, format="wav")
263
-
264
- request = TranscriptionRequest(
265
- streaming=StreamingMode.ONLINE,
266
- audio=RawAudio.from_audio(audio),
267
- language=None,
268
- )
269
- # mistral tokenizer takes care
270
- # of preparing the first prompt inputs
271
- # and does some left-silence padding
272
- # for improved performance
273
- audio_enc = tokenizer.mistral.encode_transcription(request)
274
-
275
- token_ids = audio_enc.tokens
276
- new_audio = audio_enc.audios[0].audio_array
277
-
278
- is_first_yield = False
279
- else:
280
- # pop last element from input_stream
281
- all_outputs = await asyncio.wait_for(
282
- input_stream.get(), timeout=VLLM_ENGINE_ITERATION_TIMEOUT_S
283
- )
284
- token_ids = all_outputs[-1:]
285
-
286
- multi_modal_data = {"audio": (new_audio, None)}
287
- yield TokensPrompt(
288
- prompt_token_ids=token_ids, multi_modal_data=multi_modal_data
246
+ # Get prompt tokens (streaming prefix tokens) without encoding audio
247
+ prompt_tokens = (
248
+ tokenizer.instruct.start() + audio_encoder.encode_streaming_tokens()
249
+ )
250
+
251
+ # Get left/right padding audio
252
+ left_pad, right_pad = audio_encoder.get_padding_audio()
253
+
254
+ buffer = VoxtralRealtimeBuffer(config, prompt_tokens)
255
+
256
+ # Feed audio with padding into buffer in background
257
+ async def feed_audio():
258
+ yielded_first_chunk = False
259
+ async for audio_chunk in audio_stream:
260
+ if not yielded_first_chunk:
261
+ yielded_first_chunk = True
262
+ # Prepend left padding before first real audio
263
+ await buffer.append_audio(left_pad.audio_array)
264
+ await buffer.append_audio(audio_chunk)
265
+ # Append right padding at the end
266
+ await buffer.append_audio(right_pad.audio_array)
267
+ await buffer.append_audio(None) # signal end
268
+
269
+ # Feed output tokens back into buffer in background
270
+ async def feed_tokens():
271
+ while True:
272
+ all_outputs = await asyncio.wait_for(
273
+ input_stream.get(),
274
+ timeout=VLLM_ENGINE_ITERATION_TIMEOUT_S,
275
)
276
+ await buffer.append_tokens(all_outputs[-1:])
277
+
278
+ audio_task = asyncio.create_task(feed_audio())
279
+ token_task = asyncio.create_task(feed_tokens())
280
+
281
+ try:
282
+ async for streaming_input in buffer.get_input_stream():
283
+ yield streaming_input.prompt
284
+ finally:
285
+ audio_task.cancel()
286
+ token_task.cancel()
287
288
@property
289
def audio_config(self):