feat: Add video support for the Transformers backend (#57441)
pre-commit / pre-run-check (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s

Signed-off-by: Harshal Janjani <harshaljanjani@gmail.com>
Signed-off-by: Harry Mellor <19981378+hmellor@users.noreply.github.com>
Co-authored-by: Harry Mellor <19981378+hmellor@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Harshal Janjani
2026-10-05 00:51:15 -07:00
committed by GitHub
co-authored by Harry Mellor Claude Opus 5
parent 042ab0305c
commit 34051ad714
9 changed files with 775 additions and 115 deletions
+1 -3
View File
@@ -19,12 +19,10 @@ vLLM also supports model implementations that are available in Transformers. We
Currently, the Transformers modeling backend works for the following: Currently, the Transformers modeling backend works for the following:
- Modalities: embedding models, language models, vision-language models* and audio-language models - Modalities: embedding models, language models, vision-language models and audio-language models
- Architectures: encoder-only, decoder-only, mixture-of-experts - Architectures: encoder-only, decoder-only, mixture-of-experts
- Attention types: full attention and/or sliding attention - Attention types: full attention and/or sliding attention
_*Vision-language models currently accept only image inputs. Support for video inputs will be added in a future release._
If the Transformers model implementation follows all the steps in [writing a custom model](#writing-custom-models) then, when used with the Transformers modeling backend, it will be compatible with the following features of vLLM: If the Transformers model implementation follows all the steps in [writing a custom model](#writing-custom-models) then, when used with the Transformers modeling backend, it will be compatible with the following features of vLLM:
- All the features listed in the [compatibility matrix](../features/README.md#feature-x-feature) - All the features listed in the [compatibility matrix](../features/README.md#feature-x-feature)
@@ -69,13 +69,8 @@ AUDIO_MODEL_SETTINGS: dict[str, dict[str, Any]] = {
} }
@pytest.mark.parametrize("model_id", list(AUDIO_MODEL_SETTINGS)) @pytest.fixture(autouse=True)
def test_transformers_audio_generation( def use_spawn_for_audio_models(monkeypatch):
hf_runner: type[HfRunner],
vllm_runner: type[VllmRunner],
monkeypatch,
model_id: str,
):
"""Single-process workaround for V1 fork safety deadlock issue """Single-process workaround for V1 fork safety deadlock issue
(vllm-project/vllm/issues/17676). Running multiple audio models together (vllm-project/vllm/issues/17676). Running multiple audio models together
under pytest can cause (possibly flaky) hangs, so they are grouped under under pytest can cause (possibly flaky) hangs, so they are grouped under
@@ -87,6 +82,13 @@ def test_transformers_audio_generation(
disable_envs_cache() disable_envs_cache()
monkeypatch.setenv("VLLM_WORKER_MULTIPROC_METHOD", "spawn") monkeypatch.setenv("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
@pytest.mark.parametrize("model_id", list(AUDIO_MODEL_SETTINGS))
def test_transformers_audio_generation(
hf_runner: type[HfRunner],
vllm_runner: type[VllmRunner],
model_id: str,
):
settings = AUDIO_MODEL_SETTINGS[model_id] settings = AUDIO_MODEL_SETTINGS[model_id]
audio_lora_path = settings.get("audio_lora_path") audio_lora_path = settings.get("audio_lora_path")
@@ -0,0 +1,100 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from typing import Any
import pytest
from transformers import AutoModelForImageTextToText
from vllm.assets.video import VideoAsset
from vllm.envs import disable_envs_cache
from ....conftest import HfRunner, VllmRunner
from ...utils import check_logprobs_close
from .vlm_utils import model_utils
VIDEO_ASSET = VideoAsset("baby_reading", num_frames=8)
VIDEO_MODEL_SETTINGS: dict[str, dict[str, Any]] = {
"Qwen/Qwen3-VL-2B-Instruct": {
"prompt": (
"<|im_start|>user\n"
"<|vision_start|><|video_pad|><|vision_end|>"
"Describe this video.<|im_end|>\n"
"<|im_start|>assistant\n"
),
},
"llava-hf/llava-onevision-qwen2-0.5b-ov-hf": {
"prompt": (
"<|im_start|>user <video>\nDescribe this video.<|im_end|>"
"<|im_start|>assistant\n"
),
},
"CohereLabs/North-Micro-Vision-Instruct": {
"prompt": (
"<BOS_TOKEN><|START_OF_TURN_TOKEN|><|USER_TOKEN|>"
"<|VISION_START|><|VIDEO_PAD|><|VISION_END|>Describe this video."
"<|END_OF_TURN_TOKEN|><|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>"
),
},
"Qwen/Qwen2.5-VL-3B-Instruct": {
"prompt": (
"<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n"
"<|im_start|>user\n"
"<|vision_start|><|video_pad|><|vision_end|>"
"Describe this video.<|im_end|>\n"
"<|im_start|>assistant\n"
),
},
}
@pytest.fixture(autouse=True)
def use_spawn_for_video_models(monkeypatch):
"""Single-process workaround for V1 fork safety deadlock issue
(vllm-project/vllm/issues/17676). Running multiple video models together
under pytest can cause (possibly flaky) hangs, so they are grouped under
the same config. Using VLLM_WORKER_MULTIPROC_METHOD=spawn avoids the
deadlock and allows worker processes to terminate cleanly, and release
GPU memory between test runs until the issue is fixed."""
# TODO: Remove monkeypatch once
# https://github.com/vllm-project/vllm/issues/17676 is fixed.
disable_envs_cache()
monkeypatch.setenv("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
@pytest.mark.parametrize("model_id", list(VIDEO_MODEL_SETTINGS))
def test_transformers_video_generation(
hf_runner: type[HfRunner],
vllm_runner: type[VllmRunner],
model_id: str,
):
prompt = VIDEO_MODEL_SETTINGS[model_id]["prompt"]
video = (VIDEO_ASSET.np_ndarrays, VIDEO_ASSET.metadata)
with vllm_runner(
model_id,
model_impl="transformers",
dtype="bfloat16",
max_model_len=8192,
enforce_eager=True,
limit_mm_per_prompt={"video": 1},
) as vllm_model:
vllm_outputs = vllm_model.generate_greedy_logprobs(
[prompt], 128, num_logprobs=10, videos=[video]
)
with hf_runner(
model_id, dtype="bfloat16", auto_cls=AutoModelForImageTextToText
) as hf_model:
hf_model = model_utils.qwen3_vl_patch_hf_runner(hf_model)
hf_outputs = hf_model.generate_greedy_logprobs_limit(
[prompt], 128, num_logprobs=10, videos=[video]
)
check_logprobs_close(
outputs_0_lst=hf_outputs,
outputs_1_lst=vllm_outputs,
name_0="hf",
name_1="vllm",
)
@@ -0,0 +1,277 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import numpy as np
import pytest
from transformers import Qwen3VLVideoProcessor
from vllm.config import ModelConfig
from vllm.multimodal import MULTIMODAL_REGISTRY
from vllm.multimodal.cache import MultiModalProcessorOnlyCache
VIDEO_MODEL_SETTINGS = {
"Qwen/Qwen3-VL-2B-Instruct": {
"prompt": (
"<|im_start|>user\n"
"<|vision_start|><|video_pad|><|vision_end|>"
"Describe this video.<|im_end|>\n"
"<|im_start|>assistant\n"
),
},
"llava-hf/llava-onevision-qwen2-0.5b-ov-hf": {
"prompt": (
"<|im_start|>user <video>\nDescribe this video.<|im_end|>"
"<|im_start|>assistant\n"
),
},
"CohereLabs/North-Micro-Vision-Instruct": {
"prompt": (
"<BOS_TOKEN><|START_OF_TURN_TOKEN|><|USER_TOKEN|>"
"<|VISION_START|><|VIDEO_PAD|><|VISION_END|>Describe this video."
"<|END_OF_TURN_TOKEN|><|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>"
),
},
"Qwen/Qwen2.5-VL-3B-Instruct": {
"prompt": (
"<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n"
"<|im_start|>user\n"
"<|vision_start|><|video_pad|><|vision_end|>"
"Describe this video.<|im_end|>\n"
"<|im_start|>assistant\n"
),
},
}
def _video(
num_frames: int = 8,
seed: int = 0,
fps: float = 1.0,
do_sample_frames: bool = False,
) -> tuple[np.ndarray, dict]:
rng = np.random.default_rng(seed)
frames = rng.integers(256, size=(num_frames, 64, 64, 3), dtype=np.uint8)
metadata = {
"fps": fps,
"total_num_frames": num_frames,
"frames_indices": list(range(num_frames)),
"do_sample_frames": do_sample_frames,
}
return frames, metadata
def _hf_video_kwargs(*videos):
return {
"videos": [frames for frames, _ in videos],
"video_metadata": [
{k: v for k, v in metadata.items() if k != "do_sample_frames"}
for _, metadata in videos
],
"do_sample_frames": videos[0][1]["do_sample_frames"],
}
@pytest.mark.parametrize("model_id", list(VIDEO_MODEL_SETTINGS))
def test_video_multimodal_processor(model_id):
mm_processor = MULTIMODAL_REGISTRY.create_processor(
ModelConfig(model=model_id, model_impl="transformers")
)
hf_processor = mm_processor.info.get_hf_processor()
video = _video()
frames, _ = video
result = mm_processor(
prompt=VIDEO_MODEL_SETTINGS[model_id]["prompt"],
mm_items=mm_processor.info.parse_mm_data({"video": video}),
hf_processor_mm_kwargs={},
)
hf_inputs = hf_processor(
text=VIDEO_MODEL_SETTINGS[model_id]["prompt"],
**_hf_video_kwargs(video),
return_mm_token_type_ids=True,
return_tensors="pt",
)
assert result["prompt_token_ids"] == hf_inputs["input_ids"][0].tolist()
(placeholder,) = result["mm_placeholders"]["video"]
(num_video_tokens,) = hf_processor._get_num_multimodal_tokens(
video_sizes=[frames.shape[:3]]
)["num_video_tokens"]
assert placeholder.get_num_embeds() == num_video_tokens
assert placeholder.get_num_embeds() == int(
(hf_inputs["mm_token_type_ids"] == 2).sum()
)
(item,) = result["mm_kwargs"]["video"]
rows = item["pixel_values_videos"].data.shape[0]
if "video_grid_thw" in item:
assert rows == int(item["num_video_patches"].data)
else:
assert rows == len(frames)
@pytest.mark.parametrize("model_id", list(VIDEO_MODEL_SETTINGS))
def test_video_multiple_inputs(model_id):
"""Multiple videos per prompt are each detected as a separate placeholder
and multi-modal item by the Transformers modeling backend."""
mm_processor = MULTIMODAL_REGISTRY.create_processor(
ModelConfig(model=model_id, model_impl="transformers")
)
hf_processor = mm_processor.info.get_hf_processor()
messages = [
{
"role": "user",
"content": [
{"type": "video"},
{"type": "video"},
{"type": "text", "text": "Compare these videos."},
],
}
]
prompt = hf_processor.apply_chat_template(
messages, add_generation_prompt=True, tokenize=False
)
videos = [_video(8, seed=0), _video(8, seed=1)]
result = mm_processor(
prompt=prompt,
mm_items=mm_processor.info.parse_mm_data({"video": videos}),
hf_processor_mm_kwargs={},
)
assert len(result["mm_placeholders"]["video"]) == 2
assert len(result["mm_kwargs"]["video"]) == 2
hf_inputs = hf_processor(
text=prompt, **_hf_video_kwargs(*videos), return_tensors="pt"
)
assert result["prompt_token_ids"] == hf_inputs["input_ids"][0].tolist()
def test_video_fields_not_claimed_by_image():
"""A prompt holding an image and a video keeps the fields of each on its own
modality, so the image branch does not claim the video processor's outputs."""
model_id = "Qwen/Qwen3-VL-2B-Instruct"
mm_processor = MULTIMODAL_REGISTRY.create_processor(
ModelConfig(model=model_id, model_impl="transformers")
)
result = mm_processor(
prompt=(
"<|vision_start|><|image_pad|><|vision_end|> and "
"<|vision_start|><|video_pad|><|vision_end|> What do these show?"
),
mm_items=mm_processor.info.parse_mm_data(
{"image": _video()[0][0], "video": _video()}
),
hf_processor_mm_kwargs={},
)
(image_item,) = result["mm_kwargs"]["image"]
(video_item,) = result["mm_kwargs"]["video"]
assert {"pixel_values", "image_grid_thw"} <= set(image_item.keys())
assert {"pixel_values_videos", "video_grid_thw"} <= set(video_item.keys())
assert len(result["mm_placeholders"]["image"]) == 1
assert len(result["mm_placeholders"]["video"]) == 1
def test_repeated_video_hits_the_processor_cache():
"""Check that mm caching is actually working."""
model_config = ModelConfig(
model="Qwen/Qwen3-VL-2B-Instruct", model_impl="transformers"
)
model_config.multimodal_config.mm_processor_cache_gb = 4
mm_processor = MULTIMODAL_REGISTRY.create_processor(model_config)
cache = MultiModalProcessorOnlyCache(model_config)
video = _video()
def process():
return mm_processor(
prompt=VIDEO_MODEL_SETTINGS["Qwen/Qwen3-VL-2B-Instruct"]["prompt"],
mm_items=mm_processor.info.parse_mm_data({"video": video}),
hf_processor_mm_kwargs={},
cache=cache,
)
first, second = process(), process()
assert cache.make_stats().hits > 0
assert first["prompt_token_ids"] == second["prompt_token_ids"]
assert first["mm_hashes"] == second["mm_hashes"]
def test_video_unsupported_when_processor_cannot_count_tokens(monkeypatch):
"""A processor that cannot count video tokens is served as image-only."""
monkeypatch.delattr(Qwen3VLVideoProcessor, "get_num_of_video_patches")
mm_processor = MULTIMODAL_REGISTRY.create_processor(
ModelConfig(model="Qwen/Qwen3-VL-2B-Instruct", model_impl="transformers")
)
assert "video" not in mm_processor.info.get_supported_mm_limits()
def test_merged_token_fields_split_per_video():
"""VideoLLaMA3's compression mask has one row per merged token, not per patch."""
mm_processor = MULTIMODAL_REGISTRY.create_processor(
ModelConfig(model="lkhl/VideoLLaMA3-2B-Image-HF", model_impl="transformers")
)
result = mm_processor(
prompt="<|im_start|>user\n<|video_pad|>\n<|video_pad|>\nDescribe.<|im_end|>\n",
mm_items=mm_processor.info.parse_mm_data({"video": [_video(8), _video(4)]}),
hf_processor_mm_kwargs={},
)
items = result["mm_kwargs"]["video"]
assert [len(item["video_compression_mask"].data) for item in items] == [128, 64]
def test_video_sampled_by_processor():
"""Frames left for the processor to sample yield the tokens HF does."""
model_id = "Qwen/Qwen3-VL-2B-Instruct"
mm_processor = MULTIMODAL_REGISTRY.create_processor(
ModelConfig(model=model_id, model_impl="transformers")
)
hf_processor = mm_processor.info.get_hf_processor()
prompt = VIDEO_MODEL_SETTINGS[model_id]["prompt"]
video = _video(64, fps=30.0, do_sample_frames=True)
result = mm_processor(
prompt=prompt,
mm_items=mm_processor.info.parse_mm_data({"video": video}),
hf_processor_mm_kwargs={},
)
hf_inputs = hf_processor(
text=prompt,
**_hf_video_kwargs(video),
return_mm_token_type_ids=True,
return_tensors="pt",
)
assert result["prompt_token_ids"] == hf_inputs["input_ids"][0].tolist()
(placeholder,) = result["mm_placeholders"]["video"]
assert placeholder.get_num_embeds() == int(
(hf_inputs["mm_token_type_ids"] == 2).sum()
)
@pytest.mark.parametrize("model_id", list(VIDEO_MODEL_SETTINGS))
def test_dummy_video_has_the_most_tokens(model_id):
"""Profiling must build a dummy video with exactly the most tokens a video
can produce, or memory is under- or over-reserved."""
mm_processor = MULTIMODAL_REGISTRY.create_processor(
ModelConfig(model=model_id, model_impl="transformers")
)
info = mm_processor.info
seq_len = info.ctx.model_config.max_model_len
result = mm_processor.get_dummy_mm_inputs({"video": 1})
(placeholder,) = result["mm_placeholders"]["video"]
max_tokens = info.get_max_video_tokens(seq_len, {"video": 1})
assert placeholder.get_num_embeds() == max_tokens
# Shrinking the dummy frames must never cost tokens
num_frames = info.get_num_frames_with_most_features(seq_len, {"video": 1})
(huge_frame_tokens,) = info.get_hf_processor()._get_num_multimodal_tokens(
video_sizes=[[num_frames, 10_000, 10_000]]
)["num_video_tokens"]
assert max_tokens >= huge_frame_tokens
+13 -6
View File
@@ -587,12 +587,14 @@ class MarkingStub(SupportsMultiModal, nn.Module):
_pre_trained_model_classes = Base._pre_trained_model_classes _pre_trained_model_classes = Base._pre_trained_model_classes
def build_marked_model(image_limit: int, skip_tokenizer_init: bool = False): def build_marked_model(
image_limit: int, video_limit: int = 0, skip_tokenizer_init: bool = False
):
"""Build the HF model inside the marking context and return it.""" """Build the HF model inside the marking context and return it."""
model_config = ModelConfig( model_config = ModelConfig(
model=MULTIMODAL_MODEL, model=MULTIMODAL_MODEL,
model_impl="transformers", model_impl="transformers",
limit_mm_per_prompt={"image": image_limit}, limit_mm_per_prompt={"image": image_limit, "video": video_limit},
) )
# Set after construction: building the config itself needs the tokenizer # Set after construction: building the config itself needs the tokenizer
model_config.skip_tokenizer_init = skip_tokenizer_init model_config.skip_tokenizer_init = skip_tokenizer_init
@@ -606,10 +608,15 @@ def build_marked_model(image_limit: int, skip_tokenizer_init: bool = False):
return stub.model return stub.model
@pytest.mark.parametrize(("image_limit", "skipped"), [(0, True), (4, False)]) @pytest.mark.parametrize(
def test_tower_weights_skipped_when_modality_disabled(image_limit, skipped): ("image_limit", "video_limit", "skipped"),
"""`--limit-mm-per-prompt image=0` should drop the vision tower's weights.""" [(0, 0, True), (4, 0, False), (0, 4, False)],
vision_tower = build_marked_model(image_limit).vision_tower )
def test_tower_weights_skipped_when_modality_disabled(
image_limit, video_limit, skipped
):
"""Only `--limit-mm-per-prompt image=0,video=0` drops the tower they share."""
vision_tower = build_marked_model(image_limit, video_limit).vision_tower
assert isinstance(vision_tower, StageMissingLayer) is skipped assert isinstance(vision_tower, StageMissingLayer) is skipped
+6 -18
View File
@@ -37,7 +37,7 @@ from vllm.transformers_utils.config import (
get_pooling_config, get_pooling_config,
get_sentence_transformer_tokenizer_config, get_sentence_transformer_tokenizer_config,
is_encoder_decoder, is_encoder_decoder,
is_rope_parameters_nested, iter_rope_parameters,
mrope_num_dims, mrope_num_dims,
try_get_dense_modules, try_get_dense_modules,
try_get_generation_config, try_get_generation_config,
@@ -2566,24 +2566,14 @@ def _get_and_verify_max_len(
) )
derived_max_model_len = default_max_len derived_max_model_len = default_max_len
# In Transformers v5 rope_parameters could be TypedDict or dict[str, TypedDict]. # Layers without RoPE do not contribute to context length scaling.
# To simplify the verification, we convert it to dict[str, TypedDict]. rope_parameters = list(iter_rope_parameters(hf_config))
rope_parameters = getattr(hf_config, "rope_parameters", None)
if rope_parameters and not is_rope_parameters_nested(rope_parameters):
rope_parameters = {"": rope_parameters}
if rope_parameters is not None:
# Layers without RoPE do not contribute to context length scaling.
rope_parameters = {
layer_type: rp
for layer_type, rp in rope_parameters.items()
if rp is not None
}
# NOTE(woosuk): Gemma3's max_model_len (128K) is already scaled by RoPE # NOTE(woosuk): Gemma3's max_model_len (128K) is already scaled by RoPE
# scaling, so we skip applying the scaling factor again. # scaling, so we skip applying the scaling factor again.
if rope_parameters is not None and "gemma3" not in hf_config.model_type: if rope_parameters and "gemma3" not in hf_config.model_type:
scaling_factor = 1.0 scaling_factor = 1.0
for rp in rope_parameters.values(): for rp in rope_parameters:
# No need to consider "type" key because of patch_rope_parameters when # No need to consider "type" key because of patch_rope_parameters when
# loading HF config # loading HF config
rope_type = rp["rope_type"] rope_type = rp["rope_type"]
@@ -2623,9 +2613,7 @@ def _get_and_verify_max_len(
if max_model_len is None or max_model_len == -1: if max_model_len is None or max_model_len == -1:
# For LongRoPE, default to original_max_position_embeddings to avoid # For LongRoPE, default to original_max_position_embeddings to avoid
# performance degradation for shorter sequences # performance degradation for shorter sequences
if rope_parameters is not None and any( if any(rp["rope_type"] == "longrope" for rp in rope_parameters):
rp["rope_type"] == "longrope" for rp in rope_parameters.values()
):
max_model_len = int( max_model_len = int(
getattr( getattr(
hf_config, "original_max_position_embeddings", derived_max_model_len hf_config, "original_max_position_embeddings", derived_max_model_len
@@ -16,14 +16,15 @@
# limitations under the License. # limitations under the License.
"""Transformers modeling backend mixin for multi-modal models.""" """Transformers modeling backend mixin for multi-modal models."""
import math
from collections import defaultdict from collections import defaultdict
from collections.abc import Iterable, Mapping, Sequence from collections.abc import Callable, Iterable, Mapping, Sequence
from contextlib import ExitStack, contextmanager from contextlib import ExitStack, contextmanager
from functools import cached_property
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import torch import torch
import transformers import transformers
from transformers.utils.generic import ModelOutput
from vllm.compilation.decorators import should_torch_compile_mm_encoder from vllm.compilation.decorators import should_torch_compile_mm_encoder
from vllm.config.utils import getattr_iter from vllm.config.utils import getattr_iter
@@ -47,6 +48,7 @@ from vllm.multimodal.processing import (
PromptUpdateDetails, PromptUpdateDetails,
cached_encode, cached_encode,
) )
from vllm.multimodal.processing.processor import HFMultiModalInputs
from vllm.sequence import IntermediateTensors from vllm.sequence import IntermediateTensors
from vllm.utils.gpu_sync_debug import gpu_sync_allowed from vllm.utils.gpu_sync_debug import gpu_sync_allowed
from vllm.utils.torch_utils import async_tensor_h2d from vllm.utils.torch_utils import async_tensor_h2d
@@ -62,7 +64,19 @@ if TYPE_CHECKING:
logger = init_logger(__name__) logger = init_logger(__name__)
_MODALITY_TO_TOKEN_TYPE_ID = {"image": 1, "video": 2, "audio": 3} _MODALITY_TO_TOKEN_TYPE_ID = {"image": 1, "video": 2, "audio": 3}
_MODALITY_SIZE_KEYS = {"audio": "num_audio_tokens", "image": "num_image_patches"} _MODALITY_SIZE_KEYS = {
"audio": "num_audio_tokens",
"image": "num_image_patches",
"video": "num_video_patches",
}
# NOTE: Profiling cap as in llava_onevision._MAX_FRAMES_PER_VIDEO
# past which a pixel budget only shrinks the frames, so Qwen3-VL's most is
# 12168 tokens at 16 frames and 9600 at its `max_frames` of 768
_MAX_FRAMES_PER_VIDEO = 16
# Arbitrary large size, bounded only by the processor's own resizing
_MAX_DUMMY_SIDE = 10_000
# Tiny inputs trip processors' channel-axis inference
_MIN_DUMMY_SIDE = 224
def _get_embed_token_id(replacement_ids: torch.Tensor) -> int: def _get_embed_token_id(replacement_ids: torch.Tensor) -> int:
@@ -70,6 +84,15 @@ def _get_embed_token_id(replacement_ids: torch.Tensor) -> int:
return int(replacement_ids.mode().values) return int(replacement_ids.mode().values)
def _count_embed_tokens(seqs: list[list[int]]) -> torch.Tensor:
"""Number of embedding tokens in each item's replacement."""
counts = []
for seq in seqs:
ids = torch.tensor(seq)
counts.append(int(ids.eq(_get_embed_token_id(ids)).sum()))
return torch.tensor(counts)
class MultiModalProcessingInfo(BaseProcessingInfo): class MultiModalProcessingInfo(BaseProcessingInfo):
def _get_audio_processor(self) -> Any: def _get_audio_processor(self) -> Any:
# TODO: drop feature_extractor branch once huggingface/transformers#44394 lands. # TODO: drop feature_extractor branch once huggingface/transformers#44394 lands.
@@ -77,23 +100,62 @@ class MultiModalProcessingInfo(BaseProcessingInfo):
self.get_hf_processor(), ("audio_processor", "feature_extractor") self.get_hf_processor(), ("audio_processor", "feature_extractor")
) )
@cached_property
def _is_audio_model(self) -> bool: def _is_audio_model(self) -> bool:
return self._get_audio_processor() is not None return self._get_audio_processor() is not None
@cached_property
def _is_image_model(self) -> bool: def _is_image_model(self) -> bool:
return hasattr(self.get_hf_processor(), "image_processor") return hasattr(self.get_hf_processor(), "image_processor")
@cached_property
def _is_video_model(self) -> bool:
if not hasattr(self.get_hf_processor(), "video_processor"):
return False
try:
Base.check_version("5.18.0", "video inputs")
except ImportError as e:
logger.info_once("%s, so video inputs are disabled.", e)
return False
num_frames = self._get_min_video_frames()
side = _MIN_DUMMY_SIDE
# TODO: Drop the except branch once every video processor can count, see
# https://github.com/huggingface/transformers/issues/43329
try:
mm_tokens = self._get_num_mm_tokens(video_sizes=([num_frames, side, side],))
except AttributeError:
logger.info_once(
"%s cannot count video tokens yet, so the Transformers modeling "
"backend serves this model without video inputs. See "
"https://github.com/huggingface/transformers/issues/43329",
type(self.get_hf_processor()).__name__,
)
return False
return mm_tokens["num_video_tokens"] is not None
@cached_property
def _video_needs_metadata(self) -> bool:
video_processor = getattr(self.get_hf_processor(), "video_processor", None)
return getattr(video_processor, "do_sample_frames", False)
def _get_min_video_frames(self) -> int:
video_processor = self.get_hf_processor().video_processor
# A processor without `temporal_patch_size` groups no frames, so one is enough
return getattr(video_processor, "temporal_patch_size", 1)
def _get_supported_modalities(self) -> list[str]: def _get_supported_modalities(self) -> list[str]:
modalities = [] modalities = []
if self._is_audio_model(): if self._is_audio_model:
modalities.append("audio") modalities.append("audio")
if self._is_image_model(): if self._is_image_model:
modalities.append("image") modalities.append("image")
if self._is_video_model:
modalities.append("video")
if not modalities: if not modalities:
raise ValueError( raise ValueError(
f"{type(self.get_hf_processor()).__name__} exposes neither an image " f"{type(self.get_hf_processor()).__name__} exposes no image, video "
"processor nor an audio processor, so the Transformers modeling " "or audio processor, so the Transformers modeling backend cannot "
"backend cannot serve this model as multi-modal." "serve this model as multi-modal."
) )
return modalities return modalities
@@ -104,12 +166,13 @@ class MultiModalProcessingInfo(BaseProcessingInfo):
return 16000.0 return 16000.0
def get_data_parser(self) -> MultiModalDataParser: def get_data_parser(self) -> MultiModalDataParser:
if self._is_audio_model(): target_sr = self._get_audio_sampling_rate() if self._is_audio_model else None
return MultiModalDataParser( return MultiModalDataParser(
target_sr=self._get_audio_sampling_rate(), target_sr=target_sr,
expected_hidden_size=self._get_expected_hidden_size(), video_needs_metadata=self._video_needs_metadata,
) expected_hidden_size=self._get_expected_hidden_size(),
return super().get_data_parser() allow_missing_mm_embeddings=self.allow_missing_mm_embeddings,
)
def get_supported_mm_limits(self): def get_supported_mm_limits(self):
return dict.fromkeys(self._get_supported_modalities()) return dict.fromkeys(self._get_supported_modalities())
@@ -121,6 +184,8 @@ class MultiModalProcessingInfo(BaseProcessingInfo):
max_tokens["audio"] = self.get_max_audio_tokens() max_tokens["audio"] = self.get_max_audio_tokens()
if "image" in modalities: if "image" in modalities:
max_tokens["image"] = self.get_max_image_tokens() max_tokens["image"] = self.get_max_image_tokens()
if "video" in modalities:
max_tokens["video"] = self.get_max_video_tokens(seq_len, mm_counts)
return max_tokens return max_tokens
def get_max_audio_tokens(self) -> int: def get_max_audio_tokens(self) -> int:
@@ -136,30 +201,127 @@ class MultiModalProcessingInfo(BaseProcessingInfo):
f"The following attribute names were checked: {names}." f"The following attribute names were checked: {names}."
) )
def get_max_image_tokens(self) -> int: def _get_num_mm_tokens(self, **sizes: Sequence[Sequence[int]]) -> Any:
width, height = self.get_image_size_with_most_features()
processor = self.get_hf_processor() processor = self.get_hf_processor()
multimodal_config = self.ctx.model_config.get_multimodal_config() multimodal_config = self.ctx.model_config.get_multimodal_config()
mm_processor_kwargs = multimodal_config.mm_processor_kwargs or {} mm_processor_kwargs = multimodal_config.mm_processor_kwargs or {}
mm_tokens = processor._get_num_multimodal_tokens( return processor._get_num_multimodal_tokens(**sizes, **mm_processor_kwargs)
image_sizes=([height, width],), **mm_processor_kwargs
) def get_max_image_tokens(self) -> int:
image_tokens = mm_tokens["num_image_tokens"][0] size = self.get_image_size_with_most_features()
return image_tokens return self._get_num_image_tokens(size)
def get_max_video_tokens(self, seq_len: int, mm_counts: Mapping[str, int]) -> int:
num_frames = self.get_num_frames_with_most_features(seq_len, mm_counts)
return self._get_max_video_tokens_for_frames(num_frames)
def _get_max_video_tokens_for_frames(self, num_frames: int) -> int:
size = self.get_video_size_with_most_features(num_frames)
return self._get_num_video_tokens(num_frames, size)
def _get_num_image_tokens(self, size: ImageSize) -> int:
mm_tokens = self._get_num_mm_tokens(image_sizes=([size.height, size.width],))
return mm_tokens["num_image_tokens"][0]
def _get_num_video_tokens(self, num_frames: int, size: ImageSize) -> int:
video_sizes = ([num_frames, size.height, size.width],)
mm_tokens = self._get_num_mm_tokens(video_sizes=video_sizes)
return mm_tokens["num_video_tokens"][0]
def _get_size_candidates(
self, sub_processor: Any, divisors: tuple[int, ...]
) -> list[ImageSize]:
"""Candidate sizes read off a sub-processor's `size`.
The keys are one of `VALID_SIZE_DICT_KEYS`, so the bound is either exact
or an area budget, which `divisors` splits over its items. `shortest_edge`
bounds only the small side, so it yields no candidate.
`longest_edge` is read as the area budget Qwen and GLM use it for. A
processor using it as an edge length (SmolVLM) yields a tiny candidate,
which is only chosen if it ties on tokens, so it is still correct.
"""
size = getattr(sub_processor, "size", None) or {}
height = size.get("height", size.get("max_height"))
width = size.get("width", size.get("max_width"))
if height and width:
return [ImageSize(width=width, height=height)]
if max_pixels := size.get("max_pixels", size.get("longest_edge")):
sides = {math.isqrt(max_pixels // divisor) for divisor in divisors}
return [
ImageSize(width=side, height=side) for side in sorted(sides) if side
]
return []
def _get_size_with_most_tokens(
self, candidates: list[ImageSize], count: Callable[[ImageSize], int]
) -> ImageSize:
"""Smallest size found that yields the most tokens.
`size` bounds the resized output, not the token count, so the candidates
are compared by token count against an arbitrary large fallback, which
tiling processors need. The fallback goes last so a candidate wins a tie.
Resize rounding can give a large input fewer tokens (Qwen2.5-VL), so the
candidates matter for the count, not only for memory.
The winner is then halved while the count holds, so processors with no
usable bound aren't profiled on huge dummy inputs.
"""
sizes = [*candidates, ImageSize(width=_MAX_DUMMY_SIDE, height=_MAX_DUMMY_SIDE)]
num_tokens = [count(size) for size in sizes]
most_tokens = max(num_tokens)
size = sizes[num_tokens.index(most_tokens)]
while min(size.width, size.height) // 2 >= _MIN_DUMMY_SIDE:
smaller = ImageSize(width=size.width // 2, height=size.height // 2)
if count(smaller) < most_tokens:
break
size = smaller
return size
def get_image_size_with_most_features(self) -> ImageSize: def get_image_size_with_most_features(self) -> ImageSize:
return ImageSize(width=10_000, height=10_000) # arbitrary very large size return self._get_size_with_most_tokens(
self._get_size_candidates(self.get_hf_processor().image_processor, (1,)),
self._get_num_image_tokens,
)
def get_video_size_with_most_features(self, num_frames: int) -> ImageSize:
"""Per-frame size that yields the most video tokens.
The video processor's `size` budget may cover one frame, every frame or
every temporal patch, so all three splits are tried.
"""
video_processor = self.get_hf_processor().video_processor
grid_t = max(num_frames // self._get_min_video_frames(), 1)
return self._get_size_with_most_tokens(
self._get_size_candidates(video_processor, (num_frames, grid_t, 1)),
lambda size: self._get_num_video_tokens(num_frames, size),
)
def get_num_frames_with_most_features(
self, seq_len: int, mm_counts: Mapping[str, int]
) -> int:
max_videos = max(mm_counts.get("video", 0), 1)
step = self._get_min_video_frames()
num_frames = step
while (
num_frames + step <= _MAX_FRAMES_PER_VIDEO
and self._get_max_video_tokens_for_frames(num_frames + step) * max_videos
<= seq_len
):
num_frames += step
return num_frames
class MultiModalDummyInputsBuilder(BaseDummyInputsBuilder[MultiModalProcessingInfo]): class MultiModalDummyInputsBuilder(BaseDummyInputsBuilder[MultiModalProcessingInfo]):
def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str: def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str:
text = "" text = ""
if self.info._is_audio_model() and (num_audios := mm_counts.get("audio", 0)): if self.info._is_audio_model and (num_audios := mm_counts.get("audio", 0)):
processor = self.info.get_hf_processor() processor = self.info.get_hf_processor()
audio_token = getattr(processor, "audio_token", "") audio_token = getattr(processor, "audio_token", "")
# Separated so that adjacent placeholders stay distinguishable # Separated so that adjacent placeholders stay distinguishable
text += " ".join([audio_token] * num_audios) text += " ".join([audio_token] * num_audios)
if self.info._is_image_model() and (num_images := mm_counts.get("image", 0)): if self.info._is_image_model and (num_images := mm_counts.get("image", 0)):
processor = self.info.get_hf_processor() processor = self.info.get_hf_processor()
if "gemma3" in processor.__class__.__name__.lower(): if "gemma3" in processor.__class__.__name__.lower():
image_token = processor.boi_token image_token = processor.boi_token
@@ -171,6 +333,10 @@ class MultiModalDummyInputsBuilder(BaseDummyInputsBuilder[MultiModalProcessingIn
end_token = getattr(processor, "image_end_token", "") end_token = getattr(processor, "image_end_token", "")
image_token = f"{start_token}{image_token}{end_token}" image_token = f"{start_token}{image_token}{end_token}"
text += image_token * num_images text += image_token * num_images
if self.info._is_video_model and (num_videos := mm_counts.get("video", 0)):
processor = self.info.get_hf_processor()
video_token = getattr(processor, "video_token", "")
text += video_token * num_videos
return text return text
def get_dummy_mm_data( def get_dummy_mm_data(
@@ -180,7 +346,7 @@ class MultiModalDummyInputsBuilder(BaseDummyInputsBuilder[MultiModalProcessingIn
mm_options: "MultiModalDummyOptions", mm_options: "MultiModalDummyOptions",
) -> MultiModalDataDict: ) -> MultiModalDataDict:
data = MultiModalDataBuiltins() data = MultiModalDataBuiltins()
if self.info._is_audio_model() and (num_audios := mm_counts.get("audio", 0)): if self.info._is_audio_model and (num_audios := mm_counts.get("audio", 0)):
sampling_rate = self.info._get_audio_sampling_rate() sampling_rate = self.info._get_audio_sampling_rate()
sub = self.info._get_audio_processor() sub = self.info._get_audio_processor()
chunk_length = getattr(sub, "chunk_length", None) if sub else None chunk_length = getattr(sub, "chunk_length", None) if sub else None
@@ -191,7 +357,7 @@ class MultiModalDummyInputsBuilder(BaseDummyInputsBuilder[MultiModalProcessingIn
num_audios=num_audios, num_audios=num_audios,
overrides=mm_options.get("audio"), overrides=mm_options.get("audio"),
) )
if self.info._is_image_model() and (num_images := mm_counts.get("image", 0)): if self.info._is_image_model and (num_images := mm_counts.get("image", 0)):
width, height = self.info.get_image_size_with_most_features() width, height = self.info.get_image_size_with_most_features()
data["image"] = self._get_dummy_images( data["image"] = self._get_dummy_images(
width=width, width=width,
@@ -199,6 +365,36 @@ class MultiModalDummyInputsBuilder(BaseDummyInputsBuilder[MultiModalProcessingIn
num_images=num_images, num_images=num_images,
overrides=mm_options.get("image"), overrides=mm_options.get("image"),
) )
if self.info._is_video_model and (num_videos := mm_counts.get("video", 0)):
num_frames = self.info.get_num_frames_with_most_features(seq_len, mm_counts)
width, height = self.info.get_video_size_with_most_features(num_frames)
videos = self._get_dummy_videos(
width=width,
height=height,
num_frames=num_frames,
num_videos=num_videos,
overrides=mm_options.get("video"),
)
# Only processors that sample frames need metadata, which has the
# dummy frames consumed verbatim
if self.info._video_needs_metadata:
video_processor = self.info.get_hf_processor().video_processor
fps = getattr(video_processor, "fps", None)
videos = [
(
video,
{
"fps": fps,
"duration": len(video) / fps if fps else None,
"total_num_frames": len(video),
"frames_indices": list(range(len(video))),
"video_backend": "opencv",
"do_sample_frames": False,
},
)
for video in videos
]
data["video"] = videos
return data return data
@@ -211,6 +407,40 @@ class MultiModalProcessor(BaseMultiModalProcessor[MultiModalProcessingInfo]):
and with it the multi-modal processor cache. and with it the multi-modal processor cache.
""" """
def _get_hf_mm_inputs(
self,
mm_items: MultiModalDataItems,
hf_kwargs: Mapping[str, object],
) -> HFMultiModalInputs:
"""Pass the video metadata along to the HF processor."""
hf_inputs = super()._get_hf_mm_inputs(mm_items, hf_kwargs)
hf_data = hf_inputs.hf_data
if "videos" not in hf_data or not self.info._video_needs_metadata:
return hf_inputs
videos = hf_data["videos"]
assert isinstance(videos, Sequence)
videos, metadata = zip(*videos)
hf_data["videos"] = list(videos)
hf_data["video_metadata"] = [
{k: v for k, v in item.items() if k != "do_sample_frames"}
for item in metadata
]
do_sample_frames = {item.get("do_sample_frames", False) for item in metadata}
if len(do_sample_frames) > 1:
raise ValueError(
"The videos in a request must agree on `do_sample_frames`, "
"since the HF processor takes one value for all of them."
)
return hf_inputs._replace(
hf_kwargs={
"do_sample_frames": do_sample_frames.pop(),
**hf_inputs.hf_kwargs,
}
)
def _get_modality_field_names(self, modality: str) -> set[str]: def _get_modality_field_names(self, modality: str) -> set[str]:
"""Names of the fields the sub-processor for `modality` produces.""" """Names of the fields the sub-processor for `modality` produces."""
# TODO: use else branch only once huggingface/transformers#44394 lands. # TODO: use else branch only once huggingface/transformers#44394 lands.
@@ -287,23 +517,18 @@ class MultiModalProcessor(BaseMultiModalProcessor[MultiModalProcessingInfo]):
modalities = [m for m, size in sizes.items() if size is not None] modalities = [m for m, size in sizes.items() if size is not None]
# Keys we wrote ourselves, rather than ones a sub-processor produced # Keys we wrote ourselves, rather than ones a sub-processor produced
own_keys = set(_MODALITY_SIZE_KEYS.values()) | { own_keys = {*_MODALITY_SIZE_KEYS.values(), "num_video_tokens"} | {
f"{modality}_replacement_{suffix}" f"{modality}_replacement_{suffix}"
for modality in modalities for modality in modalities
for suffix in ("ids", "sizes") for suffix in ("ids", "sizes")
} }
# Registered by name below, so no sub-processor has to claim them
own_keys |= {"image_grid_thw", "video_grid_thw", "second_per_grid_ts"}
keys = [key for key in hf_inputs if key not in own_keys] keys = [key for key in hf_inputs if key not in own_keys]
owned = self._partition_keys_by_modality(keys, modalities) owned = self._partition_keys_by_modality(keys, modalities)
# Un-padded fields are already one entry per item, so index rather than slice mm_fields = {
mm_fields: dict[str, MultiModalFieldConfig] = { key: self._get_field_config(modality, key, hf_inputs, sizes[modality])
key: MultiModalFieldConfig.batched(modality)
if modality == "audio" or isinstance(hf_inputs[key], list)
else MultiModalFieldConfig.flat_from_sizes(
modality,
sizes[modality],
dim=self._get_slice_dim(hf_inputs[key], int(sizes[modality].sum())),
)
for modality in modalities for modality in modalities
for key in owned[modality] for key in owned[modality]
} }
@@ -326,13 +551,51 @@ class MultiModalProcessor(BaseMultiModalProcessor[MultiModalProcessingInfo]):
mm_fields["image_grid_thw"] = MultiModalFieldConfig.batched( mm_fields["image_grid_thw"] = MultiModalFieldConfig.batched(
"image", keep_on_cpu=True "image", keep_on_cpu=True
) )
# TODO: route to "video" once the video modality is supported if "video" in modalities:
mm_fields["video_grid_thw"] = MultiModalFieldConfig.batched( mm_fields["video_grid_thw"] = MultiModalFieldConfig.batched(
"image", keep_on_cpu=True "video", keep_on_cpu=True
)
mm_fields["second_per_grid_ts"] = MultiModalFieldConfig.batched(
"video", keep_on_cpu=True
) )
return mm_fields return mm_fields
def _get_field_config(
self,
modality: str,
key: str,
hf_inputs: "BatchFeature",
sizes: torch.Tensor,
) -> MultiModalFieldConfig:
data = hf_inputs[key]
# Un-padded fields are already one entry per item, so index rather than slice
if modality == "audio" or isinstance(data, list):
return MultiModalFieldConfig.batched(modality)
total = int(sizes.sum())
dim = self._get_slice_dim(data, total)
if modality != "video":
return MultiModalFieldConfig.flat_from_sizes(modality, sizes, dim=dim)
rows = data.shape[dim]
if rows == len(sizes):
return MultiModalFieldConfig.batched("video")
if rows == total:
return MultiModalFieldConfig.flat_from_sizes("video", sizes, dim=dim)
# VideoLLaMA3's compression mask has one row per token the processor counts
num_video_tokens = hf_inputs["num_video_tokens"]
if rows == int(num_video_tokens.sum()):
return MultiModalFieldConfig.flat_from_sizes(
"video", num_video_tokens, dim=dim
)
# NOTE: Any other layout would need a per-model guess, which we'd like to avoid
raise ValueError(
f"{type(self.info.get_hf_processor()).__name__} returned {rows} row(s) of "
f"`{key}` for {len(sizes)} video(s) with {total} patch(es), so the rows "
"cannot be attributed to a video."
)
def _get_hf_mm_text(self, mm_counts: Mapping[str, int]) -> str: def _get_hf_mm_text(self, mm_counts: Mapping[str, int]) -> str:
return self.dummy_inputs.get_dummy_text(mm_counts) return self.dummy_inputs.get_dummy_text(mm_counts)
@@ -569,11 +832,15 @@ class MultiModalProcessor(BaseMultiModalProcessor[MultiModalProcessingInfo]):
hf_inputs, hf_data, len(seqs) hf_inputs, hf_data, len(seqs)
) )
elif modality == "audio": elif modality == "audio":
counts = [] hf_inputs["num_audio_tokens"] = _count_embed_tokens(seqs)
for seq in seqs: elif modality == "video":
ids = torch.tensor(seq) grid = hf_inputs.get("video_grid_thw")
counts.append(int(ids.eq(_get_embed_token_id(ids)).sum())) hf_inputs["num_video_patches"] = (
hf_inputs["num_audio_tokens"] = torch.tensor(counts) grid.prod(-1)
if grid is not None
else torch.ones(len(seqs), dtype=torch.long)
)
hf_inputs["num_video_tokens"] = _count_embed_tokens(seqs)
hf_inputs.pop("input_ids") hf_inputs.pop("input_ids")
@@ -833,25 +1100,47 @@ class MultiModalMixin(SupportsMultiModal, SupportsMRoPE, Base):
if pixel_values is None: if pixel_values is None:
return None return None
num_image_patches = kwargs.pop("num_image_patches") return self._process_vision_input(
"image", pixel_values, kwargs.pop("num_image_patches"), **kwargs
)
split_sizes = num_image_patches.flatten().tolist() def _process_video_input(self, **kwargs) -> list[torch.Tensor] | None:
pixel_values_videos: torch.Tensor | None = kwargs.pop(
"pixel_values_videos", None
)
if pixel_values_videos is None:
return None
kwargs.pop("second_per_grid_ts", None)
return self._process_vision_input(
"video", pixel_values_videos, kwargs.pop("num_video_patches"), **kwargs
)
def _process_vision_input(
self,
modality: str,
pixel_values: torch.Tensor,
num_patches: torch.Tensor,
**kwargs,
) -> list[torch.Tensor]:
split_sizes = num_patches.flatten().tolist()
if isinstance(pixel_values, torch.Tensor): if isinstance(pixel_values, torch.Tensor):
vision_embeddings = self._get_image_features(pixel_values, **kwargs) vision_embeddings = self._get_features(modality, pixel_values, **kwargs)
if isinstance(vision_embeddings, torch.Tensor): if isinstance(vision_embeddings, torch.Tensor):
return self._split_embeddings(vision_embeddings, split_sizes) return self._split_embeddings(vision_embeddings, split_sizes)
return list(vision_embeddings) return list(vision_embeddings)
# Images the processor left un-padded arrive as a list once their # Items the processor left un-padded arrive as a list once their
# shapes differ. Encode them one at a time so that none of them is # shapes differ. Encode them one at a time so that none of them is
# padded to match another. # padded to match another.
embeddings: list[torch.Tensor] = [] embeddings: list[torch.Tensor] = []
for index, image in enumerate(pixel_values): for index, item in enumerate(pixel_values):
features = self._get_image_features( features = self._get_features(
image.unsqueeze(0), modality,
item.unsqueeze(0),
**self._select_item_kwargs(kwargs, index, len(pixel_values)), **self._select_item_kwargs(kwargs, index, len(pixel_values)),
) )
# Encoders which return one entry per image return a single entry # Encoders which return one entry per item return a single entry
if not isinstance(features, torch.Tensor): if not isinstance(features, torch.Tensor):
features = torch.cat(list(features)) features = torch.cat(list(features))
embeddings.extend(self._split_embeddings(features, [split_sizes[index]])) embeddings.extend(self._split_embeddings(features, [split_sizes[index]]))
@@ -870,35 +1159,31 @@ class MultiModalMixin(SupportsMultiModal, SupportsMRoPE, Base):
for key, value in kwargs.items() for key, value in kwargs.items()
} }
def _get_image_features(self, pixel_values: torch.Tensor, **kwargs) -> Any: def _get_features(self, modality: str, pixel_values: torch.Tensor, **kwargs) -> Any:
# grid_thw fields are registered keep_on_cpu; restore the on-device # grid_thw fields are registered keep_on_cpu; restore the on-device
# placement that HF get_image_features implementations expect. # placement that HF get_*_features implementations expect.
for key, value in kwargs.items(): for key, value in kwargs.items():
if isinstance(value, torch.Tensor) and value.is_cpu: if isinstance(value, torch.Tensor) and value.is_cpu:
kwargs[key] = async_tensor_h2d(value, pixel_values.device) kwargs[key] = async_tensor_h2d(value, pixel_values.device)
# The underlying HuggingFace `get_image_features` implementations # The underlying HuggingFace `get_*_features` implementations
# contain model-internal syncs (e.g. Idefics3 filters all-zero # contain model-internal syncs (e.g. Idefics3 filters all-zero
# padding images via boolean-mask indexing, LlavaOnevision # padding images via boolean-mask indexing, LlavaOnevision
# branches on per-sample batch counts). # branches on per-sample batch counts).
with gpu_sync_allowed(): with gpu_sync_allowed():
features = self.model.get_image_features(pixel_values, **kwargs) get_modality_features = getattr(self.model, f"get_{modality}_features")
features = get_modality_features(pixel_values, return_dict=True, **kwargs)
# Transformers `v5`, `self.get_image_features` returns a tuple return features.pooler_output
# containing the features and optionally attentions/hidden_states
# After v5 is settled, we can enable qwen3-vl with several outputs
# from `self.get_image_features`
if isinstance(features, tuple):
return features[0]
if isinstance(features, ModelOutput):
return features.pooler_output
return features
def embed_multimodal(self, **kwargs) -> MultiModalEmbeddings: def embed_multimodal(self, **kwargs) -> MultiModalEmbeddings:
# Each helper detects its own inputs. We are called once per modality, so the # Each helper detects its own inputs. We are called once per modality, so the
# leftovers a helper forwards to the HF model can't belong to the other one. # leftovers a helper forwards to the HF model can't belong to the other one.
embeddings: list[torch.Tensor] = [] embeddings: list[torch.Tensor] = []
for process_input in (self._process_audio_input, self._process_image_input): for process_input in (
self._process_audio_input,
self._process_image_input,
self._process_video_input,
):
embeddings.extend(process_input(**kwargs) or []) embeddings.extend(process_input(**kwargs) or [])
return embeddings return embeddings
@@ -917,16 +1202,21 @@ class MultiModalMixin(SupportsMultiModal, SupportsMRoPE, Base):
"use_audio_in_video", "use_audio_in_video",
}, },
) )
if any(v for k, v in kwargs.items() if k not in {"image_grid_thw"}): if kwargs.get("audio_feature_lengths") or kwargs.get("use_audio_in_video"):
raise NotImplementedError( raise NotImplementedError(
"Transformers modeling backend only supports images." "Transformers modeling backend does not support audio inputs in "
"M-RoPE models."
) )
image_grid_thw = kwargs.get("image_grid_thw", []) image_grid_thw = kwargs.get("image_grid_thw", [])
video_grid_thw = kwargs.get("video_grid_thw", []) video_grid_thw = kwargs.get("video_grid_thw", [])
second_per_grid_ts = kwargs.get("second_per_grid_ts", [])
image_grid_thw = torch.stack(image_grid_thw) if image_grid_thw else None image_grid_thw = torch.stack(image_grid_thw) if image_grid_thw else None
video_grid_thw = torch.stack(video_grid_thw) if video_grid_thw else None video_grid_thw = torch.stack(video_grid_thw) if video_grid_thw else None
second_per_grid_ts = (
torch.stack(second_per_grid_ts) if second_per_grid_ts else None
)
# `get_rope_index` doesn't always accept arbitrary `kwargs` # `get_rope_index` doesn't always accept arbitrary `kwargs`
if not hasattr(self, "_get_rope_index_kwarg_names"): if not hasattr(self, "_get_rope_index_kwarg_names"):
@@ -950,6 +1240,7 @@ class MultiModalMixin(SupportsMultiModal, SupportsMRoPE, Base):
for name, value in ( for name, value in (
("image_grid_thw", image_grid_thw), ("image_grid_thw", image_grid_thw),
("video_grid_thw", video_grid_thw), ("video_grid_thw", video_grid_thw),
("second_per_grid_ts", second_per_grid_ts),
) )
if value is not None or accepts_kwarg(name) if value is not None or accepts_kwarg(name)
} }
@@ -958,8 +1249,11 @@ class MultiModalMixin(SupportsMultiModal, SupportsMRoPE, Base):
for feature in mm_features: for feature in mm_features:
position = feature.mm_position position = feature.mm_position
offset, length = position.offset, position.length offset, length = position.offset, position.length
is_embed = position.is_embed
if is_embed is None:
is_embed = slice(None)
mm_token_type_id = _MODALITY_TO_TOKEN_TYPE_ID[feature.modality] mm_token_type_id = _MODALITY_TO_TOKEN_TYPE_ID[feature.modality]
mm_token_type_ids[offset : offset + length] = mm_token_type_id mm_token_type_ids[offset : offset + length][is_embed] = mm_token_type_id
kwargs["mm_token_type_ids"] = mm_token_type_ids.unsqueeze(0) kwargs["mm_token_type_ids"] = mm_token_type_ids.unsqueeze(0)
mrope_positions, mrope_position_delta = self.model.get_rope_index( mrope_positions, mrope_position_delta = self.model.get_rope_index(
@@ -36,7 +36,7 @@ from vllm.model_executor.layers.linear import (
) )
from vllm.model_executor.layers.vocab_parallel_embedding import VocabParallelEmbedding from vllm.model_executor.layers.vocab_parallel_embedding import VocabParallelEmbedding
from vllm.model_executor.models.utils import maybe_prefix from vllm.model_executor.models.utils import maybe_prefix
from vllm.transformers_utils.config import is_rope_parameters_nested from vllm.transformers_utils.config import iter_rope_parameters
if TYPE_CHECKING: if TYPE_CHECKING:
from vllm.config import VllmConfig from vllm.config import VllmConfig
@@ -366,10 +366,4 @@ def can_enable_torch_compile(vllm_config: "VllmConfig") -> bool:
""" """
text_config = vllm_config.model_config.hf_config.get_text_config() text_config = vllm_config.model_config.hf_config.get_text_config()
# Dynamic rope scaling is not compatible with torch.compile # Dynamic rope scaling is not compatible with torch.compile
rope_parameters: dict | None = getattr(text_config, "rope_parameters", None) or {} return all(rp["rope_type"] != "dynamic" for rp in iter_rope_parameters(text_config))
if rope_parameters:
# Nest rope_parameters if not nested already to simplify logic
if not is_rope_parameters_nested(rope_parameters):
rope_parameters = {"": rope_parameters}
return all(rp["rope_type"] != "dynamic" for rp in rope_parameters.values())
return True
+4 -4
View File
@@ -683,15 +683,15 @@ def patch_rope_parameters(config: PreTrainedConfig) -> None:
config.validate_rope() config.validate_rope()
def _iter_rope_parameters(config: PreTrainedConfig) -> Iterator[dict[str, Any]]: def iter_rope_parameters(config: PreTrainedConfig) -> Iterator[dict[str, Any]]:
"""Yield a config's rope parameters, one dict per layer type if nested.""" """Yield a config's rope parameters, one dict per layer type if nested."""
rope_parameters = getattr(config, "rope_parameters", None) rope_parameters = getattr(config, "rope_parameters", None)
if not isinstance(rope_parameters, dict): if not isinstance(rope_parameters, dict):
return return
if is_rope_parameters_nested(rope_parameters): if is_rope_parameters_nested(rope_parameters):
yield from (p for p in rope_parameters.values() if isinstance(p, dict)) yield from (p for p in rope_parameters.values() if isinstance(p, dict) and p)
else: elif rope_parameters:
yield rope_parameters yield rope_parameters
@@ -705,7 +705,7 @@ def _mrope_section(config: PreTrainedConfig) -> Sequence[int] | None:
names = ("mrope_section", "xdrope_section") names = ("mrope_section", "xdrope_section")
for params in _iter_rope_parameters(config): for params in iter_rope_parameters(config):
for i, name in enumerate(names): for i, name in enumerate(names):
section = params.get(name) section = params.get(name)
if isinstance(section, (list, tuple)): if isinstance(section, (list, tuple)):