Files
sglang/test/registered/vlm/test_vlm_input_format.py

354 lines
13 KiB
Python

import json
import unittest
from io import BytesIO
from typing import Optional
import requests
import torch
# Compatibility shim: Kimi-VL dynamic module expects PytorchGELUTanh which may
# be missing in transformers==4.57.1. Inject a lightweight implementation so
# the model can import successfully without downgrading transformers.
import transformers.activations as _hf_activations
from PIL import Image
from transformers import (
AutoModel,
AutoProcessor,
Gemma3ForConditionalGeneration,
Qwen2_5_VLForConditionalGeneration,
)
from sglang.test.ci.ci_register import register_cuda_ci
if not hasattr(_hf_activations, "PytorchGELUTanh"):
class PytorchGELUTanh(torch.nn.Module):
def forward(self, x):
return torch.nn.functional.gelu(x, approximate="tanh")
_hf_activations.PytorchGELUTanh = PytorchGELUTanh
_hf_activations.ACT2FN.setdefault(
"pytorch_gelu_tanh",
lambda x: torch.nn.functional.gelu(x, approximate="tanh"),
)
from sglang import Engine
from sglang.srt.entrypoints.openai.protocol import ChatCompletionRequest
from sglang.srt.parser.conversation import generate_chat_conv
register_cuda_ci(est_time=447, suite="stage-b-test-large-1-gpu")
IMAGE_MAN_IRONING_URL = "https://raw.githubusercontent.com/sgl-project/sgl-test-files/refs/heads/main/images/man_ironing_on_back_of_suv.png"
IMAGE_SGL_LOGO_URL = "https://raw.githubusercontent.com/sgl-project/sgl-test-files/refs/heads/main/images/sgl_logo.png"
class VLMInputTestBase:
model_path = None
chat_template = None
processor = None
visual = None # Should be a callable for precomputed embeddings
@classmethod
def setUpClass(cls):
assert cls.model_path is not None, "Set model_path in subclass"
assert cls.chat_template is not None, "Set chat_template in subclass"
cls.image_urls = [IMAGE_MAN_IRONING_URL, IMAGE_SGL_LOGO_URL]
cls.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
cls.main_image = []
for image_url in cls.image_urls:
response = requests.get(image_url)
cls.main_image.append(Image.open(BytesIO(response.content)))
cls.processor = AutoProcessor.from_pretrained(
cls.model_path, trust_remote_code=True, use_fast=True
)
cls._init_visual()
@classmethod
def _init_visual(cls):
"""Override in subclass to set up cls.visual as a callable for precomputed embeddings."""
raise NotImplementedError
def setUp(self):
self.engine = Engine(
model_path=self.model_path,
chat_template=self.chat_template,
device=self.device.type,
mem_fraction_static=0.8,
enable_multimodal=True,
disable_cuda_graph=True,
trust_remote_code=True,
)
def tearDown(self):
self.engine.shutdown()
def verify_response(self, output):
# The goal is to check that the model roughly understands:
# - image 1: taxi / car scene
# - image 2: SGL logo / company
# We intentionally keep the check keyword-based and loose to avoid
# overfitting to a specific phrasing.
out_text = output["text"].lower()
assert any(w in out_text for w in ("taxi", "cab", "car")), out_text
has_sg_or_logo_side = any(
kw in out_text
for kw in (
"sg ",
"sgl",
" sgl",
"logo",
"software guidance",
"labs",
"laborator",
"company",
" text",
)
)
assert has_sg_or_logo_side, out_text
def get_completion_request(self) -> ChatCompletionRequest:
json_structure = {
"model": self.model_path,
"messages": [
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": self.image_urls[0]}},
{"type": "image_url", "image_url": {"url": self.image_urls[1]}},
{
"type": "text",
"text": "Describe both the first image and the second image in detail separately.", # update prompt, ensure kimi-vl understands the images separately.
},
],
}
],
}
json_str = json.dumps(json_structure)
return ChatCompletionRequest.model_validate_json(json_str)
def get_processor_output(self, req: Optional[ChatCompletionRequest] = None):
if req is None:
req = self.get_completion_request()
conv = generate_chat_conv(req, template_name=self.chat_template)
text = conv.get_prompt()
# Process inputs using processor
inputs = self.processor(
text=[text],
images=self.main_image,
return_tensors="pt",
).to(self.device)
return inputs, text
async def test_accepts_image(self):
req = self.get_completion_request()
conv = generate_chat_conv(req, template_name=self.chat_template)
text = conv.get_prompt()
output = await self.engine.async_generate(
prompt=text,
image_data=self.main_image,
sampling_params=dict(temperature=0.0, max_new_tokens=512),
)
self.verify_response(output)
async def test_accepts_precomputed_embeddings(self):
req = self.get_completion_request()
processor_output, _ = self.get_processor_output(req=req)
with torch.inference_mode():
precomputed_embeddings = self.__class__.visual(processor_output)
output = await self.engine.async_generate(
input_ids=processor_output["input_ids"][0].detach().cpu().tolist(),
image_data=[
self._precomputed_image_data(processor_output, precomputed_embeddings)
],
sampling_params=dict(temperature=0.0, max_new_tokens=512),
)
self.verify_response(output)
async def test_accepts_processor_output(self):
req = self.get_completion_request()
processor_output, prompt = self.get_processor_output(req=req)
output = await self.engine.async_generate(
input_ids=processor_output["input_ids"][0].detach().cpu().tolist(),
image_data=[self._processor_output_image_data(processor_output)],
sampling_params=dict(temperature=0.0, max_new_tokens=512),
)
self.verify_response(output)
def _precomputed_image_data(self, processor_output, precomputed_embeddings):
"""This should not be overridden."""
return dict(
processor_output,
format="precomputed_embedding",
feature=precomputed_embeddings,
)
def _processor_output_image_data(self, processor_output):
"""Override in subclass to pass the correct set of arguments."""
raise NotImplementedError
class TestQwenVLUnderstandsImage(VLMInputTestBase, unittest.IsolatedAsyncioTestCase):
model_path = "Qwen/Qwen2.5-VL-3B-Instruct"
chat_template = "qwen2-vl"
@classmethod
def _init_visual(cls):
cls.visual_model = (
Qwen2_5_VLForConditionalGeneration.from_pretrained(
cls.model_path, torch_dtype=torch.bfloat16
)
.eval()
.visual.to(cls.device)
)
cls.visual = lambda processor_output: cls.visual_model(
processor_output["pixel_values"], processor_output["image_grid_thw"]
)
def _processor_output_image_data(self, processor_output):
return dict(processor_output, format="processor_output")
class TestGemmaUnderstandsImage(VLMInputTestBase, unittest.IsolatedAsyncioTestCase):
model_path = "google/gemma-3-4b-it"
chat_template = "gemma-it"
@classmethod
def _init_visual(cls):
model = Gemma3ForConditionalGeneration.from_pretrained(
cls.model_path, torch_dtype=torch.bfloat16
)
base_model = model.model
cls.vision_tower = base_model.vision_tower.eval().to(cls.device)
if hasattr(base_model, "multi_modal_projector"):
cls.mm_projector = base_model.multi_modal_projector.eval().to(cls.device)
else:
cls.mm_projector = model.multi_modal_projector.eval().to(cls.device)
cls.visual = lambda processor_output: cls.mm_projector(
cls.vision_tower(
pixel_values=processor_output["pixel_values"]
).last_hidden_state
)
def _processor_output_image_data(self, processor_output):
return dict(processor_output, format="processor_output")
# Updated Kimi-VL test to use the new input format.
class TestKimiVLImageUnderstandsImage(
VLMInputTestBase, unittest.IsolatedAsyncioTestCase
):
model_path = "moonshotai/Kimi-VL-A3B-Instruct"
chat_template = "kimi-vl"
@classmethod
def _init_visual(cls):
model = AutoModel.from_pretrained(cls.model_path, trust_remote_code=True)
cls.vision_tower = model.vision_tower.eval().to(cls.device)
cls.mm_projector = model.multi_modal_projector.eval().to(cls.device)
cls.visual = lambda tokenizer_output: cls.mm_projector(
cls.vision_tower(
pixel_values=tokenizer_output["pixel_values"],
grid_hws=tokenizer_output["image_grid_hws"],
)
)
def _processor_output_image_data(self, processor_output):
return dict(processor_output, format="processor_output")
# not for CI: too large
# class TestLlama4ImageUnderstandsImage(
# VLMInputTestBase, unittest.IsolatedAsyncioTestCase
# ):
# # Allow overriding via env for local/offline runs.
# model_path = "meta-llama/Llama-4-Scout-17B-16E-Instruct"
# chat_template = "llama-4"
# def setUp(self):
# if torch.cuda.device_count() < 4:
# self.skipTest("Skipping Llama-4 test: requires 4 GPUs for TP=4")
# self.engine = Engine(
# model_path=self.model_path,
# trust_remote_code=True,
# chat_template=self.chat_template,
# enable_multimodal=True,
# mem_fraction_static=0.8,
# tp_size=4,
# attention_backend="fa3",
# context_length=65536,
# )
# @classmethod
# def _init_visual(cls):
# model = AutoModel.from_pretrained(
# cls.model_path,
# trust_remote_code=True,
# torch_dtype="auto",
# force_download=True,
# )
# cls.vision_tower = model.vision_model.eval().to(cls.device)
# cls.mm_projector = model.multi_modal_projector.eval().to(cls.device)
# cls.visual = lambda tokenizer_output: cls.mm_projector(
# cls.vision_tower(
# pixel_values=tokenizer_output["pixel_values"],
# ).last_hidden_state.flatten(0, -2)
# )
# def _processor_output_image_data(self, processor_output):
# # Llama-4 vision expects processor_output format with pixel_values
# return dict(processor_output, format="processor_output")
# class TestLlavaUnderstandsImage(VLMInputTestBase, unittest.IsolatedAsyncioTestCase):
# model_path = "llava-hf/llava-1.5-7b-hf"
# chat_template = "vicuna_v1.1"
# @classmethod
# def _init_visual(cls):
# from transformers import LlavaForConditionalGeneration
# model = LlavaForConditionalGeneration.from_pretrained(
# cls.model_path,
# torch_dtype=torch.float16,
# low_cpu_mem_usage=True,
# )
# cls.vision_tower = model.vision_tower.eval().to(cls.device)
# cls.multi_modal_projector = model.multi_modal_projector.eval().to(cls.device)
# cls.config = model.config
# def visual_func(processor_output):
# pixel_values = processor_output["pixel_values"].to(
# cls.device, dtype=torch.float16
# )
# vision_outputs = cls.vision_tower(pixel_values, output_hidden_states=True)
# image_features = vision_outputs.hidden_states[-2]
# if cls.config.vision_feature_select_strategy == "default":
# image_features = image_features[:, 1:]
# elif cls.config.vision_feature_select_strategy == "full":
# image_features = image_features
# image_features = cls.multi_modal_projector(image_features)
# return image_features
# cls.visual = visual_func
# def _processor_output_image_data(self, processor_output):
# return dict(processor_output, format="processor_output")
if __name__ == "__main__":
unittest.main()