[VLM] Split Pixtral multi-image features before the CUDA IPC wrap (#35463)

This commit is contained in:
Mohammad Miadh Angkad
2026-08-22 15:17:55 +08:00
committed by GitHub
parent 6fd0384d42
commit 90354326c7
3 changed files with 247 additions and 65 deletions
@@ -1726,29 +1726,60 @@ class BaseMultimodalProcessor(ABC):
from sglang.srt.managers.mm_utils import get_new_expanded_mm_items
all_collected_items = get_new_expanded_mm_items(all_collected_items)
all_collected_items = self._finalize_mm_items(
all_collected_items,
images=base_output.images,
)
for item in all_collected_items:
return all_collected_items, input_ids, ret
def _finalize_mm_items(
self,
mm_items: List[MultimodalDataItem],
*,
images: Optional[List[Any]],
) -> List[MultimodalDataItem]:
mm_items = self._postprocess_mm_items_before_transport(
mm_items,
images=images,
)
for item in mm_items:
if item.format in (
MultimodalInputFormat.PROCESSOR_OUTPUT,
MultimodalInputFormat.PRECOMPUTED_EMBEDDING,
):
item.set_pad_value()
self._precompute_hashes_before_cpu_transfer(all_collected_items)
self._precompute_hashes_before_cpu_transfer(mm_items)
return self._prepare_mm_items_for_transport(mm_items)
# Wrap GPU features in the bounded IPC pool; pool misses fall back to a
# plain CPU tensor. The scheduler copies out and releases each slice.
if self.use_cuda_ipc:
# post-process, prepare for cuda-ipc transfer
for item in all_collected_items:
if isinstance(item.feature, torch.Tensor):
item.feature = self._wrap_tensor_for_cuda_ipc(item.feature)
if isinstance(item.precomputed_embeddings, torch.Tensor):
item.precomputed_embeddings = self._wrap_tensor_for_cuda_ipc(
item.precomputed_embeddings
)
def _postprocess_mm_items_before_transport(
self,
mm_items: List[MultimodalDataItem],
*,
images: Optional[List[Any]],
) -> List[MultimodalDataItem]:
"""Apply model-specific item reshaping while features are still tensors."""
return mm_items
return all_collected_items, input_ids, ret
def _prepare_mm_items_for_transport(
self, mm_items: List[MultimodalDataItem]
) -> List[MultimodalDataItem]:
"""Wrap final GPU features for dispatch to the scheduler."""
if not self.use_cuda_ipc:
return mm_items
# Pool misses fall back to plain CPU tensors. The scheduler copies out
# and releases each successful pool slice.
for item in mm_items:
if isinstance(item.feature, torch.Tensor):
item.feature = self._wrap_tensor_for_cuda_ipc(item.feature)
if isinstance(item.precomputed_embeddings, torch.Tensor):
item.precomputed_embeddings = self._wrap_tensor_for_cuda_ipc(
item.precomputed_embeddings
)
return mm_items
async def process_and_combine_mm_data_async(
self,
@@ -1,12 +1,17 @@
import copy
import math
from typing import List, Union
from typing import Any, List, Optional, Union
from transformers import PreTrainedTokenizerBase
from transformers.models.pixtral.image_processing_pixtral import (
_num_image_tokens as _get_pixtral_hf_num_image_tokens,
)
from sglang.srt.managers.schedule_batch import Modality, MultimodalProcessorOutput
from sglang.srt.managers.schedule_batch import (
Modality,
MultimodalDataItem,
MultimodalProcessorOutput,
)
from sglang.srt.models.pixtral import (
PixtralForConditionalGeneration,
PixtralVisionModel,
@@ -41,6 +46,7 @@ class PixtralProcessor(BaseMultimodalProcessor):
"spatial_merge_size",
getattr(hf_config, "spatial_merge_size", 1),
)
self._effective_patch_size = self.patch_size * self._spatial_merge_size
self._processor.patch_size = self.patch_size
if self._spatial_merge_size > 1:
@@ -77,58 +83,86 @@ class PixtralProcessor(BaseMultimodalProcessor):
image_data=image_data,
return_text=True,
)
if mm_data.images:
effective_patch = self.patch_size * self._spatial_merge_size
image_nrows = []
for img in mm_data.images:
w, h = img.size
ratio = max(w / self.image_size, h / self.image_size)
if ratio > 1:
w = int(math.floor(w / ratio))
h = int(math.floor(h / ratio))
nrows, _ = _get_pixtral_hf_num_image_tokens(
(h, w), (effective_patch, effective_patch)
)
image_nrows.append(nrows)
mm_items, input_ids, _ = self.process_and_combine_mm_data(
mm_data, self.mm_tokens
)
# For multi-image: split single IMAGE mm_item into per-image items
if len(mm_data.images) > 1:
from sglang.srt.managers.schedule_batch import MultimodalDataItem
old_item = next(
item for item in mm_items if item.modality == Modality.IMAGE
)
all_offsets = old_item.offsets
old_feature = old_item.feature
old_image_sizes = getattr(old_item, "image_sizes", None)
mm_items = [
item for item in mm_items if item.modality != Modality.IMAGE
]
offset_idx = 0
for i, img in enumerate(mm_data.images):
nr = image_nrows[i]
item_offsets = all_offsets[offset_idx : offset_idx + nr]
offset_idx += nr
new_item = MultimodalDataItem(modality=Modality.IMAGE)
new_item.feature = old_feature[i : i + 1]
new_item.offsets = item_offsets
if old_image_sizes is not None:
new_item.model_specific_data["image_sizes"] = old_image_sizes[
i : i + 1
]
mm_items.append(new_item)
else:
mm_items, input_ids, _ = self.process_and_combine_mm_data(
mm_data, self.mm_tokens
)
mm_items, input_ids, _ = self.process_and_combine_mm_data(
mm_data, self.mm_tokens
)
return MultimodalProcessorOutput(
mm_items=mm_items,
input_ids=input_ids.tolist(),
im_token_id=self.IM_TOKEN_ID,
)
def _postprocess_mm_items_before_transport(
self,
mm_items: List[MultimodalDataItem],
*,
images: Optional[List[Any]],
) -> List[MultimodalDataItem]:
if not images or len(images) <= 1:
return mm_items
image_items = [item for item in mm_items if item.modality == Modality.IMAGE]
if len(image_items) == len(images):
return mm_items
if len(image_items) != 1:
raise ValueError(
"Pixtral multi-image processing expected one bundled IMAGE item or "
f"{len(images)} split items, but found {len(image_items)}"
)
old_item = image_items[0]
all_offsets = old_item.offsets
old_feature = old_item.feature
old_image_sizes = old_item.model_specific_data.get("image_sizes")
image_nrows = self._get_image_nrows(images)
if old_feature is None or len(old_feature) != len(image_nrows):
raise ValueError(
"Pixtral multi-image feature count does not match the number of "
f"images: features={0 if old_feature is None else len(old_feature)}, "
f"images={len(image_nrows)}"
)
if all_offsets is None or sum(image_nrows) != len(all_offsets):
raise ValueError(
"Pixtral image patch rows do not match the computed offsets: "
f"rows={sum(image_nrows)}, "
f"offsets={0 if all_offsets is None else len(all_offsets)}"
)
split_items = [item for item in mm_items if item.modality != Modality.IMAGE]
offset_idx = 0
for image_idx, num_rows in enumerate(image_nrows):
item_offsets = all_offsets[offset_idx : offset_idx + num_rows]
offset_idx += num_rows
new_item = copy.copy(old_item)
new_item.feature = old_feature[image_idx : image_idx + 1]
new_item.offsets = item_offsets
new_item.model_specific_data = copy.copy(old_item.model_specific_data)
if old_image_sizes is not None:
new_item.model_specific_data["image_sizes"] = old_image_sizes[
image_idx : image_idx + 1
]
new_item.hash = None
new_item.pad_value = None
split_items.append(new_item)
if offset_idx != len(all_offsets):
raise ValueError(
"Pixtral multi-image split did not consume every offset: "
f"consumed={offset_idx}, offsets={len(all_offsets)}"
)
return split_items
def _get_image_nrows(self, images: List[Any]) -> List[int]:
image_nrows = []
for image in images:
width, height = image.size
ratio = max(width / self.image_size, height / self.image_size)
if ratio > 1:
width = int(math.floor(width / ratio))
height = int(math.floor(height / ratio))
num_rows, _ = _get_pixtral_hf_num_image_tokens(
(height, width),
(self._effective_patch_size, self._effective_patch_size),
)
image_nrows.append(num_rows)
return image_nrows
@@ -0,0 +1,117 @@
"""Regression tests for Pixtral multimodal item processing."""
import unittest
from unittest.mock import MagicMock
import torch
from PIL import Image
from sglang.srt.managers.mm_utils import get_new_expanded_mm_items
from sglang.srt.managers.schedule_batch import (
Modality,
MultimodalDataItem,
MultimodalInputFormat,
)
from sglang.srt.multimodal.processors.pixtral import PixtralProcessor
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=2, suite="base-a-test-cpu")
class TestPixtralProcessor(CustomTestCase):
def _make_processor(self):
processor = object.__new__(PixtralProcessor)
processor.use_cuda_ipc = True
processor.image_size = 1024
processor._effective_patch_size = 28
processor._precompute_hashes_before_cpu_transfer = MagicMock()
return processor
def test_multi_image_features_are_split_before_transport(self):
"""CUDA IPC dispatch must receive per-image tensors, not a bundled proxy."""
processor = self._make_processor()
proxies = [object(), object()]
processor._wrap_tensor_for_cuda_ipc = MagicMock(side_effect=proxies)
feature = torch.arange(8).reshape(2, 4)
image_sizes = torch.tensor([[10, 20], [30, 40]])
bundled_item = MultimodalDataItem(
modality=Modality.IMAGE,
feature=feature,
offsets=[(0, 0), (1, 1), (2, 2), (3, 3), (4, 4)],
format=MultimodalInputFormat.PROCESSOR_OUTPUT,
model_specific_data={"image_sizes": image_sizes, "extra_key": "keep"},
)
images = [Image.new("RGB", (28, 56)), Image.new("RGB", (28, 84))]
items = processor._finalize_mm_items(
[bundled_item],
images=images,
)
self.assertEqual(len(items), 2)
self.assertEqual(
[item.offsets for item in items],
[[(0, 0), (1, 1)], [(2, 2), (3, 3), (4, 4)]],
)
self.assertTrue(
all(item.format == MultimodalInputFormat.PROCESSOR_OUTPUT for item in items)
)
self.assertTrue(
all(item.model_specific_data["extra_key"] == "keep" for item in items)
)
self.assertTrue(all(item.pad_value is not None for item in items))
self.assertTrue(
torch.equal(items[0].model_specific_data["image_sizes"], image_sizes[:1])
)
self.assertTrue(
torch.equal(items[1].model_specific_data["image_sizes"], image_sizes[1:])
)
wrapped_features = [
call.args[0] for call in processor._wrap_tensor_for_cuda_ipc.call_args_list
]
self.assertTrue(torch.equal(wrapped_features[0], feature[:1]))
self.assertTrue(torch.equal(wrapped_features[1], feature[1:]))
self.assertEqual([item.feature for item in items], proxies)
def test_already_split_one_row_images_are_preserved(self):
"""Generic per-image splits must not be collapsed and re-sliced by Pixtral."""
processor = self._make_processor()
processor._wrap_tensor_for_cuda_ipc = MagicMock(
side_effect=[object(), object()]
)
bundled_item = MultimodalDataItem(
modality=Modality.IMAGE,
feature=torch.arange(8).reshape(2, 4),
offsets=[(0, 0), (2, 2)],
)
items = get_new_expanded_mm_items([bundled_item])
images = [Image.new("RGB", (28, 28)), Image.new("RGB", (56, 28))]
items = processor._finalize_mm_items(items, images=images)
self.assertEqual(len(items), 2)
self.assertEqual([item.offsets for item in items], [[(0, 0)], [(2, 2)]])
wrapped_features = [
call.args[0] for call in processor._wrap_tensor_for_cuda_ipc.call_args_list
]
self.assertTrue(torch.equal(wrapped_features[0], bundled_item.feature[:1]))
self.assertTrue(torch.equal(wrapped_features[1], bundled_item.feature[1:]))
def test_mismatched_patch_rows_fail_loudly(self):
"""Derived row counts cannot silently leave image placeholders unassigned."""
processor = self._make_processor()
item = MultimodalDataItem(
modality=Modality.IMAGE,
feature=torch.arange(8).reshape(2, 4),
offsets=[(0, 0), (1, 1)],
)
images = [Image.new("RGB", (28, 56)), Image.new("RGB", (28, 84))]
with self.assertRaisesRegex(ValueError, "patch rows"):
processor._finalize_mm_items([item], images=images)
if __name__ == "__main__":
unittest.main()