Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f2b7ae1
[Bugfix][Multimodal] Honor modality-scoped mm_processor_kwargs in eve…
Hotragn Aug 31, 2026
3545e91
Honor per-request mm_processor_kwargs in the Transformers backend tok…
Hotragn Sep 5, 2026
763bf23
Keep the shared "most features" size bound unscoped
Hotragn Sep 7, 2026
fb57586
Assert the size bound directly in the leak regression test
Hotragn Sep 8, 2026
40d876a
Scope Cohere Compass image token counts to images_kwargs
Hotragn Sep 9, 2026
7324cf0
Thread modality through the GLM-5-Next pixel budget overrides
Hotragn Sep 10, 2026
c019f81
Merge branch 'main' into fix/modality-scoped-mm-kwargs-vl
mergify[bot] Sep 10, 2026
0a511d4
Drop the subclass signature test; pre-commit mypy already catches it
Hotragn Sep 11, 2026
bc71a8a
Merge remote-tracking branch 'upstream/main' into rebase/mm-kwargs
Hotragn Sep 12, 2026
b252bca
Merge remote-tracking branch 'upstream/main' into rebase/mm-kwargs
Hotragn Sep 14, 2026
b3706a5
Merge remote-tracking branch 'upstream/main' into rebase/mm-kwargs
Hotragn Sep 16, 2026
a2d23a5
Merge upstream/main into rebase/mm-kwargs
Hotragn Sep 16, 2026
56f1c8b
Merge remote-tracking branch 'upstream/main' into rebase/mm-kwargs
Hotragn Sep 17, 2026
45fde1e
Merge branch 'main' into fix/modality-scoped-mm-kwargs-vl
mergify[bot] Sep 18, 2026
31e47b1
Merge remote-tracking branch 'upstream/main' into rebase/mm-kwargs
Hotragn Sep 22, 2026
2e91653
Merge remote-tracking branch 'upstream/main' into rebase/mm-kwargs
Hotragn Sep 26, 2026
722ba98
Merge branch 'main' into fix/modality-scoped-mm-kwargs-vl
mergify[bot] Sep 28, 2026
0b2809f
Merge remote-tracking branch 'upstream/main' into rebase/mm-kwargs
Hotragn Sep 30, 2026
2ecbccb
[Multimodal] Port modality-scoped reads to the resolved mm_processor_…
Hotragn Sep 30, 2026
5cb2b77
Address review: call get_allowed_kwarg_only_overrides directly
Hotragn Oct 1, 2026
7cf2164
Merge branch 'main' into fix/modality-scoped-mm-kwargs-vl
mergify[bot] Oct 1, 2026
35bccee
Address review: keep request kwargs out of HF token counts
Hotragn Oct 1, 2026
b1cca50
Merge upstream/main into fix/modality-scoped-mm-kwargs-vl
Hotragn Oct 2, 2026
94e70e8
Probe the scoped patch count with a size override
Hotragn Oct 3, 2026
9c6b1a8
Size the vision bound per modality
Hotragn Oct 3, 2026
7062cbb
Merge remote-tracking branch 'upstream/main' into fix/modality-scoped…
Hotragn Oct 4, 2026
0627776
Resolve scoped images_kwargs for the profiled image-token budget
Hotragn Oct 4, 2026
e485603
Merge upstream/main into fix/modality-scoped-mm-kwargs-vl
Hotragn Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions tests/models/multimodal/processing/test_glm4_1v.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,92 @@ def test_video_loader_consistency(
static_outputs["mm_kwargs"].get_data(),
dynamic_outputs["mm_kwargs"].get_data(),
)


# Far above any stock GLM-4.1V pixel budget, so an override is unmistakable.
_SCOPED_MAX_PIXELS = 469762048


def _probe_max_pixels(
model_id: str, mm_processor_kwargs: dict | None
) -> tuple[int, int]:
"""Return the (video, image) pixel budgets vLLM computes."""
ctx = build_model_context(
model_id,
mm_processor_kwargs=mm_processor_kwargs,
limit_mm_per_prompt={"image": 1, "video": 1},
)
info = MULTIMODAL_REGISTRY.create_processor(ctx.model_config).info
return info._get_video_max_pixels(), info._get_image_max_pixels()


@pytest.mark.skip_global_cleanup
@pytest.mark.parametrize("model_id", ["zai-org/GLM-4.1V-9B-Thinking"])
def test_videos_kwargs_max_pixels_does_not_leak_into_image_budget(model_id: str):
"""A scoped ``videos_kwargs`` override must reach only the video budget.

The HF processor already honors the nested dict, so when vLLM's own
budget reads ignore it the two disagree about how many tokens a video
expands to.
"""
stock_video, stock_image = _probe_max_pixels(model_id, None)
assert stock_video != _SCOPED_MAX_PIXELS
assert stock_image != _SCOPED_MAX_PIXELS

scoped_video, scoped_image = _probe_max_pixels(
model_id, {"videos_kwargs": {"max_pixels": _SCOPED_MAX_PIXELS}}
)
assert scoped_video == _SCOPED_MAX_PIXELS
assert scoped_image == stock_image

# A flat override keeps the previous shared-namespace behavior.
flat_video, flat_image = _probe_max_pixels(
model_id, {"max_pixels": _SCOPED_MAX_PIXELS}
)
assert flat_video == _SCOPED_MAX_PIXELS
assert flat_image == _SCOPED_MAX_PIXELS


# Well below any stock GLM-4.1V image budget, so a leak into the shared
# upper bound is unmistakable.
_SMALL_MAX_PIXELS = 1_003_520


def _probe_budgets(model_id: str, mm_processor_kwargs: dict | None) -> dict:
ctx = build_model_context(
model_id,
mm_processor_kwargs=mm_processor_kwargs,
limit_mm_per_prompt={"image": 1, "video": 1},
)
info = MULTIMODAL_REGISTRY.create_processor(ctx.model_config).info
return {
"image_max_pixels": info._get_image_max_pixels(),
"size_bound": tuple(info.get_image_size_with_most_features()),
"video_frames": info._get_max_video_frames(30_000),
}


@pytest.mark.skip_global_cleanup
@pytest.mark.parametrize("model_id", ["zai-org/GLM-4.1V-9B-Thinking"])
def test_images_kwargs_max_pixels_does_not_leak_into_video_budget(model_id: str):
"""An image-scoped override must not move the shared size upper bound.

``get_image_size_with_most_features`` feeds the video frame budget and the
dummy data as well as the image budget. Scoping that bound to ``image``
would let an image-only override shrink the profiled video budget, which
no override of that modality should touch.
"""
stock = _probe_budgets(model_id, None)
scoped = _probe_budgets(
model_id, {"images_kwargs": {"max_pixels": _SMALL_MAX_PIXELS}}
)

# The override reaches the per-item image read it is meant for.
assert stock["image_max_pixels"] != _SMALL_MAX_PIXELS
assert scoped["image_max_pixels"] == _SMALL_MAX_PIXELS

# It must not reach the bound that video sizing and dummy data share.
assert (scoped["size_bound"], scoped["video_frames"]) == (
stock["size_bound"],
stock["video_frames"],
)
66 changes: 66 additions & 0 deletions tests/models/multimodal/processing/test_transformers_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,3 +314,69 @@ def test_nested_image_fields_split_per_image(processor_cls):
for item in items:
pixel_values = item["pixel_values"].data
assert pixel_values.shape[1] == int(item["num_image_patches"].data)


_MODEL_ID = "llava-hf/llava-onevision-qwen2-0.5b-ov-hf"
# The stock processor size is 384x384, so this changes the feature count.
_SCOPED_SIZE = {"height": 768, "width": 768}


def _probe_num_image_tokens(mm_processor_kwargs, request_kwargs=None) -> list[int]:
"""The per-image token counts vLLM predicts for a single image."""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move the imports to the top

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 5cb2b776ac. The probe now builds the processor with the shared create_processor(_MODEL_ID, LegacyMultiModalProcessor, mm_processor_kwargs) helper from transformers_backend.py, which this PR already extends with mm_processor_kwargs. The function-local imports are gone and the module needs no new top-level ones.

Fork run 36823193389 on head 7cf21648d2 (includes Mergify's merge of main): test_transformers_image.py 20 passed, 1 xfailed, 3 failed. The 3 failures are the same gated google/gemma-3-4b-it 401s as before. The test assertions did not change, so the earlier red-on-main result (assert [7329] == [2709], run 36673566847) still applies.

from vllm.config import ModelConfig
from vllm.model_executor.models.transformers.multimodal import (
MultiModalDummyInputsBuilder,
MultiModalProcessingInfo,
)
from vllm.multimodal.processing import InputProcessingContext
from vllm.tokenizers.registry import cached_tokenizer_from_config

model_config = ModelConfig(
model=_MODEL_ID,
model_impl="transformers",
mm_processor_kwargs=mm_processor_kwargs,
)
info = MultiModalProcessingInfo(
InputProcessingContext(model_config, cached_tokenizer_from_config(model_config))
)
mm_processor = LegacyMultiModalProcessor(info, MultiModalDummyInputsBuilder(info))
image = ImageAsset("cherry_blossom").pil_image
mm_items = mm_processor.info.parse_mm_data({"image": image})
tokens = mm_processor._get_num_multimodal_tokens(mm_items, request_kwargs or {})
return list(tokens["num_image_tokens"])


def test_scoped_images_kwargs_reach_the_token_count():
"""A nested ``images_kwargs`` override must reach vLLM's own token count.

``_get_num_multimodal_tokens`` is how vLLM predicts how many placeholder
tokens an image expands to. The HF processor honors a nested
``images_kwargs`` in its ``__call__``, so a vLLM-side read that only looks
at the flat namespace makes the two disagree.
"""
stock = _probe_num_image_tokens(None)
flat = _probe_num_image_tokens({"size": _SCOPED_SIZE})
# Precondition: this override really does move the count, so the
# assertion below cannot pass by coincidence.
assert flat != stock

scoped = _probe_num_image_tokens({"images_kwargs": {"size": _SCOPED_SIZE}})
assert scoped == flat


def test_request_mm_processor_kwargs_reach_the_token_count():
"""Per-request ``mm_processor_kwargs`` must reach vLLM's own token count too.

The request overrides build the HF processor that produces the features, so
a token count that only merges the model-config overrides predicts a
different number of placeholder tokens than the processor actually emits.
"""
stock = _probe_num_image_tokens(None)
flat = _probe_num_image_tokens({"size": _SCOPED_SIZE})
assert flat != stock

for request_kwargs in (
{"size": _SCOPED_SIZE},
{"images_kwargs": {"size": _SCOPED_SIZE}},
):
assert _probe_num_image_tokens(None, request_kwargs) == flat
8 changes: 6 additions & 2 deletions tests/models/multimodal/processing/transformers_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,14 @@
]


def create_processor(model_id: str, processor_cls):
def create_processor(model_id: str, processor_cls, mm_processor_kwargs=None):
"""Build a processor directly, because the registry only ever builds the one the
installed transformers version selects, leaving the other path untested."""
model_config = ModelConfig(model=model_id, model_impl="transformers")
model_config = ModelConfig(
model=model_id,
model_impl="transformers",
mm_processor_kwargs=mm_processor_kwargs,
)
ctx = InputProcessingContext(
model_config, cached_tokenizer_from_config(model_config)
)
Expand Down
2 changes: 1 addition & 1 deletion vllm/model_executor/models/cohere2_vision.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ def get_num_patches(
return image_processor.get_number_of_image_patches(
image_height,
image_width,
self.ctx.get_merged_mm_kwargs(mm_kwargs),
self.ctx.get_merged_mm_kwargs(mm_kwargs, modality="image"),
)


Expand Down
9 changes: 8 additions & 1 deletion vllm/model_executor/models/ernie45_vl.py
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,7 @@ def _get_vision_info(
do_resize: bool = True,
image_processor: BaseImageProcessor,
mm_kwargs: Mapping[str, object],
modality: str | None = None,
) -> tuple[ImageSize, int]:
hf_config = self.get_hf_config()
vision_config = hf_config.vision_config
Expand All @@ -930,7 +931,7 @@ def _get_vision_info(
min_pixels_key = "shortest_edge"
max_pixels_key = "longest_edge"

mm_kwargs = self.ctx.get_merged_mm_kwargs(mm_kwargs)
mm_kwargs = self.ctx.get_merged_mm_kwargs(mm_kwargs, modality=modality)
size = image_processor.size
if override_size := mm_kwargs.get("size"):
size = size | override_size
Expand Down Expand Up @@ -973,6 +974,7 @@ def get_num_image_tokens(
image_height=image_height,
image_processor=image_processor,
mm_kwargs=mm_kwargs,
modality="image",
)
return num_image_tokens

Expand All @@ -991,12 +993,17 @@ def get_num_video_tokens(
num_frames=num_frames,
image_processor=image_processor,
mm_kwargs=mm_kwargs,
modality="video",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
)
return num_video_tokens

def get_image_size_with_most_features(self) -> ImageSize:
image_processor = self.get_image_processor()

# Unscoped on purpose: this bound is shared by the image budget,
# the video budget and the dummy data, so a modality-scoped override
# must not move it. get_num_{image,video}_tokens re-resize it with
# the cap for their own modality.
max_image_size, _ = self._get_vision_info(
image_width=9999999,
image_height=9999999,
Expand Down
12 changes: 8 additions & 4 deletions vllm/model_executor/models/glm4_1v.py
Original file line number Diff line number Diff line change
Expand Up @@ -1083,15 +1083,15 @@ def _get_vision_info(

return preprocessed_size, num_vision_tokens

def _get_image_max_pixels(self) -> int:
def _get_image_max_pixels(self, modality: str | None = "image") -> int:
Comment thread
DarkLight1337 marked this conversation as resolved.
Resolved
"""Read max_pixels from the HF image processor config.

Despite the name, ``longest_edge`` is a pixel **area** (total pixel
count), not an edge length. The HF processor passes it directly to
``smart_resize`` as the ``max_pixels`` argument, which constrains
``t_bar * h_bar * w_bar <= max_pixels``.
"""
mm_kwargs = self.ctx.get_merged_mm_kwargs({})
mm_kwargs = self.ctx.get_merged_mm_kwargs({}, modality=modality)
if (override_max_pixels := mm_kwargs.get("max_pixels")) is not None:
return int(override_max_pixels)

Expand All @@ -1108,7 +1108,7 @@ def _get_image_max_pixels(self) -> int:
return self._get_longest_edge(size, "GLM4V image processor size")

def _get_video_max_pixels(self) -> int:
mm_kwargs = self.ctx.get_merged_mm_kwargs({})
mm_kwargs = self.ctx.get_merged_mm_kwargs({}, modality="video")
if (override_max_pixels := mm_kwargs.get("max_pixels")) is not None:
return int(override_max_pixels)

Expand All @@ -1129,11 +1129,15 @@ def get_image_size_with_most_features(self) -> ImageSize:
# underestimating the spatial budget for a single image and
# causing encoder cache overflow for large images
# (see https://github.com/vllm-project/vllm/issues/34040).
# The pixel bound is deliberately unscoped: it is shared by the image
# budget, the video budget and the dummy data, so a modality-scoped
# override must not move it. get_num_image_tokens and
# _get_max_video_frames re-resize it with their own cap.
max_image_size, _ = self._get_vision_info(
image_width=9999999,
image_height=9999999,
num_frames=1,
max_image_pixels=self._get_image_max_pixels(),
max_image_pixels=self._get_image_max_pixels(modality=None),
)
return max_image_size

Expand Down
2 changes: 1 addition & 1 deletion vllm/model_executor/models/idefics3.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def _get_image_feature_grid_size(
return image_processor.get_number_of_image_patches(
image_height,
image_width,
self.ctx.get_merged_mm_kwargs(mm_kwargs),
self.ctx.get_merged_mm_kwargs(mm_kwargs, modality="image"),
)

def get_num_patches(
Expand Down
2 changes: 1 addition & 1 deletion vllm/model_executor/models/interns1.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ def get_num_image_tokens(
num_image_patches = image_processor.get_number_of_image_patches(
image_height,
image_width,
self.ctx.get_merged_mm_kwargs(mm_kwargs),
self.ctx.get_merged_mm_kwargs(mm_kwargs, modality="image"),
)

return processor.image_seq_length * num_image_patches
Expand Down
9 changes: 8 additions & 1 deletion vllm/model_executor/models/keye.py
Original file line number Diff line number Diff line change
Expand Up @@ -964,14 +964,15 @@ def _get_vision_info(
do_resize: bool = True,
image_processor: BaseImageProcessor,
mm_kwargs: Mapping[str, object],
modality: str | None = None,
) -> tuple[ImageSize, int]:
hf_config = self.get_hf_config()
vision_config = hf_config.vision_config
patch_size = vision_config.patch_size
merge_size = vision_config.spatial_merge_size
temporal_patch_size = 1

mm_kwargs = self.ctx.get_merged_mm_kwargs(mm_kwargs)
mm_kwargs = self.ctx.get_merged_mm_kwargs(mm_kwargs, modality=modality)
size = image_processor.size
if override_size := mm_kwargs.get("size"):
size = size | override_size
Expand Down Expand Up @@ -1016,6 +1017,7 @@ def get_num_image_tokens(
image_height=image_height,
image_processor=image_processor,
mm_kwargs=mm_kwargs,
modality="image",
)
return num_image_tokens

Expand All @@ -1034,12 +1036,17 @@ def get_num_video_tokens(
num_frames=num_frames,
image_processor=image_processor,
mm_kwargs=mm_kwargs,
modality="video",
)
return num_video_tokens

def get_image_size_with_most_features(self) -> ImageSize:
image_processor = self.get_image_processor()

# Unscoped on purpose: this bound is shared by the image budget,
# the video budget and the dummy data, so a modality-scoped override
# must not move it. get_num_{image,video}_tokens re-resize it with
# the cap for their own modality.
max_image_size, _ = self._get_vision_info(
image_width=self.get_max_image_size(),
image_height=self.get_max_image_size(),
Expand Down
4 changes: 2 additions & 2 deletions vllm/model_executor/models/lfm2_vl.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ def _get_image_feature_grid_size(
) -> tuple[int, int, int]:
image_processor: Lfm2VlImageProcessorFast = processor.image_processor

mm_kwargs = self.ctx.get_merged_mm_kwargs(mm_kwargs)
mm_kwargs = self.ctx.get_merged_mm_kwargs(mm_kwargs, modality="image")
downsample_factor = mm_kwargs.get(
"downsample_factor", image_processor.downsample_factor
)
Expand Down Expand Up @@ -338,7 +338,7 @@ def get_num_image_tokens(
) -> tuple[int, int]:
image_processor: Lfm2VlImageProcessorFast = processor.image_processor

mm_kwargs = self.ctx.get_merged_mm_kwargs(mm_kwargs)
mm_kwargs = self.ctx.get_merged_mm_kwargs(mm_kwargs, modality="image")
downsample_factor = mm_kwargs.get(
"downsample_factor", image_processor.downsample_factor
)
Expand Down
9 changes: 8 additions & 1 deletion vllm/model_executor/models/mimo_v2_omni.py
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,7 @@ def _get_vision_info(
do_resize: bool = True,
image_processor,
mm_kwargs: Mapping[str, object],
modality: str | None = None,
) -> tuple[ImageSize, int]:
hf_config = self.get_hf_config()
vision_config = hf_config.vision_config
Expand All @@ -721,7 +722,7 @@ def _get_vision_info(
temporal_patch_size = vision_config.temporal_patch_size
tokens_per_second = vision_config.tokens_per_second

mm_kwargs = self.ctx.get_merged_mm_kwargs(mm_kwargs)
mm_kwargs = self.ctx.get_merged_mm_kwargs(mm_kwargs, modality=modality)
size = image_processor.size
if override_size := mm_kwargs.get("size"):
size = size | override_size
Expand Down Expand Up @@ -771,6 +772,7 @@ def get_num_image_tokens(
num_frames=1,
image_processor=image_processor,
mm_kwargs=mm_kwargs,
modality="image",
)
return num_image_tokens

Expand All @@ -789,6 +791,7 @@ def get_num_video_tokens(
num_frames=num_frames,
image_processor=image_processor,
mm_kwargs=mm_kwargs,
modality="video",
)
return num_video_tokens

Expand All @@ -802,6 +805,10 @@ def get_image_size_with_most_features(

if max_pixels is None:
image_processor = self.get_image_processor()
# Unscoped on purpose: this bound is shared by the image budget,
# the video budget and the dummy data, so a modality-scoped
# override must not move it. get_num_{image,video}_tokens
# re-resize it with the cap for their own modality.
mm_kwargs = self.ctx.get_merged_mm_kwargs({})
size = image_processor.size
if override_size := mm_kwargs.get("size"):
Expand Down
Loading
Loading