Problem Statement
When smg routes multimodal (VLM) requests via gRPC, the three backend engines handle image preprocessing very differently. Only SGLang and vLLM receives the full benefit of smg's Rust-based preprocessing — TensorRT-LLM still has some gaps to be filled later.
What smg's multimodal crate does today
smg's Rust multimodal crate handles the full image preprocessing pipeline:
- Image loading — fetch/decode raw images
- Image preprocessing — resize, normalize, crop (model-specific: Qwen, Qwen3, LLaVA, Phi3, Pixtral, LLaMA4, etc.)
- Token expansion — compute how many tokens each image occupies (e.g.,
N = prod(image_grid_thw) // merge_size² for Qwen)
- Model-specific metadata — produce auxiliary tensors (
image_grid_thw for Qwen, aspect_ratios for LLaMA4, image_sizes for Phi3/LLaVA, etc.)
Output: pixel_values (preprocessed image tensor) + model_specific_tensors + expanded_token_ids + mm_placeholders
Current gRPC routing by backend
Updated — PR #588 replaced the unified MultimodalData struct with backend-specific variants. Assembly is now deferred to request_building after worker selection, so each backend only serializes the fields it needs.
process_multimodal() in multimodal.rs:
fetch → preprocess → expand tokens → MultimodalIntermediate (lightweight, no serialization)
↓
(worker selection determines backend)
↓
assemble_multimodal_data(intermediate, client) →
MultimodalData::Sglang → pixel_values + model_specific + patch-only placeholders
MultimodalData::Vllm → pixel_values + model_specific + structural placeholders + hashes + field keys
MultimodalData::Trtllm → raw image bytes only (no tensor serialization)
| What smg sends |
SGLang |
vLLM |
TensorRT-LLM |
| Token IDs (expanded) |
✅ |
✅ |
❌ (unexpanded) |
| pixel_values (tensor) |
✅ |
✅ |
❌ |
| model_specific_tensors |
✅ |
✅ |
❌ |
| mm_placeholders |
✅ (patch-only) |
✅ (structural) |
❌ |
| mm_hashes (blake3) |
❌ |
✅ |
❌ |
| batched_keys / flat_keys |
❌ |
✅ |
❌ |
| Raw image bytes |
✅ |
❌ |
✅ |
How each engine processes multimodal data it receives
SGLang (most efficient — zero redundant work)
smg sends: expanded_token_ids + pixel_values + model_specific_tensors + mm_placeholders
↓
grpc_server._parse_mm_inputs()
→ Decodes TensorData proto into torch tensors
→ Creates MultimodalDataItem(feature=pixel_values, model_specific_data={image_grid_thw: ...})
↓
No HF processor called
↓
model.get_image_feature(items)
→ Uses item.feature (pixel_values) directly
→ Vision encoder (ViT) runs on GPU
Waste: None. smg does all preprocessing in Rust. SGLang just unwraps the tensors and feeds them to the ViT.
vLLM (solved by #570 — zero redundant work)
Before #570: vLLM received raw image bytes and re-ran HF processor (dummy tokenization + image resize/normalize/crop). See collapsed section below for the old flow.
smg sends: expanded_token_ids + pixel_values + model_specific_tensors
+ mm_placeholders + mm_hashes + batched_keys/flat_keys
↓
grpc_server._build_preprocessed_mm_inputs()
→ Decodes TensorData proto into torch tensors
→ Casts floating-point tensors to model dtype (bfloat16)
→ Constructs MultiModalFieldConfig from batched_keys/flat_keys
→ Builds is_embed mask on PlaceholderRange using im_token_id
→ Constructs MultiModalInputs via mm_inputs()
↓
No HF processor called
No dummy tokenization
↓
Vision encoder (ViT) runs on GPU
Waste: None. smg does all preprocessing in Rust. vLLM constructs MultiModalKwargsItems.from_hf_inputs(BatchFeature(hf_dict), fields_config) directly from the proto tensors.
Additional capabilities enabled:
- Encoder output caching — blake3 per-image hashes (
mm_hashes) enable vLLM to cache and reuse ViT encoder outputs across requests with the same images
- Prefix caching — hash-based cache salt improves prefix cache hit rates for multimodal requests
Old vLLM flow (before #570)
smg sends: token_ids + raw image bytes
↓
grpc_server builds: TokensPrompt(prompt_token_ids=[...], multi_modal_data={"image": [PIL.Image]})
↓
Renderer.process_for_engine()
→ _process_tokens()
→ _process_multimodal(prompt_token_ids=[1,2,3], mm_data={"image": PIL.Image})
→ BaseMultiModalProcessor.apply(prompt=[1,2,3], mm_items=...)
→ _apply_hf_processor_main()
├─ _apply_hf_processor_tokens_only([1,2,3]) → returns [1,2,3] UNCHANGED
└─ _apply_hf_processor_mm_only(images)
→ generates DUMMY TEXT ← wasted work
→ calls HF processor(text=dummy, images=real_images) ← re-preprocesses images
→ discards tokenized output, keeps only pixel_values
↓
Vision encoder (ViT) runs on GPU
Waste:
- Image preprocessing repeated — smg could have sent pixel_values, but instead sends raw bytes → vLLM re-runs resize/normalize/crop via HF ImageProcessor
- Dummy tokenization — HF AutoProcessor requires text input, so vLLM generates dummy text, tokenizes it, then throws it away
TensorRT-LLM (worst — full double tokenization, not yet addressed)
smg sends: token_ids + raw image bytes
↓
grpc_servicer extracts images from proto
↓
llm.generate_async(inputs={"prompt_token_ids": [...], "multi_modal_data": {"image": [bytes]}})
↓
llm._preprocess()
→ Detects: token_ids + multi_modal_data + VLM model
→ prompt = tokenizer.decode(token_ids) ← DECODES tokens back to text!
→ inputs = TextPrompt(prompt=text, multi_modal_data=images)
↓
VLM InputProcessor.__call__(text_prompt, images)
→ HF AutoProcessor(text=prompt, images=images)
→ image_processor(images) → pixel_values ← re-preprocesses images
→ tokenizer(expanded_text) → input_ids ← RE-TOKENIZES the prompt!
→ _postprocess(input_ids)
→ compute mrope, build multimodal_data
↓
Vision encoder (ViT) runs on GPU
Waste:
- Token IDs decoded back to text —
tokenizer.decode(token_ids) undoes smg's tokenization
- Full re-tokenization — HF AutoProcessor re-tokenizes the decoded text (with image placeholder expansion)
- Image preprocessing repeated — HF ImageProcessor re-runs resize/normalize/crop on raw bytes
- All three steps that smg already did (tokenize, expand, image_process) are repeated
Solution
Phase 1: vLLM — Done (#570)
PR #570 sends preprocessed pixel_values + model_specific_tensors to vLLM via gRPC, matching the SGLang path. The two-phase pipeline split from #497 has been collapsed back to a single process_multimodal() entry point.
Phase 1.5: Backend-specific multimodal data — Done (#588)
PR #588 replaced the unified MultimodalData struct with an enum of backend-specific variants (SglangMultimodalData, VllmMultimodalData, TrtllmMultimodalData). Assembly is deferred to request_building after worker selection via assemble_multimodal_data(intermediate, client), so each backend only serializes the fields it needs. TRT-LLM no longer wastes time serializing megabytes of pixel_values it never sends.
Phase 2: TensorRT-LLM — Deferred
TRT-LLM proto expansion was prototyped and reverted in #570 — TRT-LLM backend-side changes to accept preprocessed tensors are not yet ready. The proto changes for TRT-LLM will be re-added when the backend support is implemented.
Related PRs
Downstream PRs:
Additional Context
Key code references
smg:
SGLang (feat/grpc-mm branch):
vLLM:
TensorRT-LLM:
tensorrt_llm/llmapi/llm.py:489-494 — the decode-and-reprocess path (tokenizer.decode() → HF AutoProcessor)
tensorrt_llm/grpc/grpc_servicer.py — multimodal image extraction from proto
HF AutoProcessor internals (Qwen2VLProcessor as example)
The HF AutoProcessor.__call__() does 3 things:
self.image_processor(images) → pixel_values + image_grid_thw (CPU preprocessing: resize, normalize)
- Text expansion — replace
<|image_pad|> with N copies per image
self.tokenizer(expanded_text) → input_ids
It does NOT run the vision encoder (ViT). The ViT runs during model.forward().
FastImageProcessor
HuggingFace has FastImageProcessor (torchvision-based) that can run on GPU. SGLang uses it with device="cuda" by default. vLLM uses use_fast=True but defaults to CPU. This is orthogonal to the main issue — the savings come from skipping redundant preprocessing entirely, not from where resize/normalize runs.
Problem Statement
When smg routes multimodal (VLM) requests via gRPC, the three backend engines handle image preprocessing very differently. Only SGLang and vLLM receives the full benefit of smg's Rust-based preprocessing — TensorRT-LLM still has some gaps to be filled later.
What smg's
multimodalcrate does todaysmg's Rust multimodal crate handles the full image preprocessing pipeline:
N = prod(image_grid_thw) // merge_size²for Qwen)image_grid_thwfor Qwen,aspect_ratiosfor LLaMA4,image_sizesfor Phi3/LLaVA, etc.)Output:
pixel_values(preprocessed image tensor) +model_specific_tensors+expanded_token_ids+mm_placeholdersCurrent gRPC routing by backend
How each engine processes multimodal data it receives
SGLang (most efficient — zero redundant work)
Waste: None. smg does all preprocessing in Rust. SGLang just unwraps the tensors and feeds them to the ViT.
vLLM (solved by #570 — zero redundant work)
Waste: None. smg does all preprocessing in Rust. vLLM constructs
MultiModalKwargsItems.from_hf_inputs(BatchFeature(hf_dict), fields_config)directly from the proto tensors.Additional capabilities enabled:
mm_hashes) enable vLLM to cache and reuse ViT encoder outputs across requests with the same imagesOld vLLM flow (before #570)
Waste:
TensorRT-LLM (worst — full double tokenization, not yet addressed)
Waste:
tokenizer.decode(token_ids)undoes smg's tokenizationSolution
Phase 1: vLLM — Done (#570)
PR #570 sends preprocessed pixel_values + model_specific_tensors to vLLM via gRPC, matching the SGLang path. The two-phase pipeline split from #497 has been collapsed back to a single
process_multimodal()entry point.Phase 1.5: Backend-specific multimodal data — Done (#588)
PR #588 replaced the unified
MultimodalDatastruct with an enum of backend-specific variants (SglangMultimodalData,VllmMultimodalData,TrtllmMultimodalData). Assembly is deferred to request_building after worker selection viaassemble_multimodal_data(intermediate, client), so each backend only serializes the fields it needs. TRT-LLM no longer wastes time serializing megabytes of pixel_values it never sends.Phase 2: TensorRT-LLM — Deferred
TRT-LLM proto expansion was prototyped and reverted in #570 — TRT-LLM backend-side changes to accept preprocessed tensors are not yet ready. The proto changes for TRT-LLM will be re-added when the backend support is implemented.
Related PRs
Downstream PRs:
Additional Context
Key code references
smg:
process_multimodal()— async entry point: fetch → preprocess → expand tokens →MultimodalIntermediateassemble_multimodal_data()— deferred assembly:MultimodalIntermediate→ backend-specificMultimodalDatavariantSglangMultimodalData::into_proto()— sends pixel_values + model_specific_tensors + patch-only placeholdersVllmMultimodalData::into_proto()— sends pixel_values + model_specific_tensors + mm_hashes + batched_keys/flat_keysTrtllmMultimodalData::into_proto()— sends raw image bytes onlymultimodal/src/vision/processors/— per-model image preprocessors (8 families)multimodal/src/registry.rs— token expansion specs (ModelProcessorSpectrait)multimodal/src/hasher.rs— blake3 image hashingSGLang (feat/grpc-mm branch):
_parse_mm_inputs()— decodes pixel_values from proto TensorData_decode_tensor_data()— TensorData proto → torch tensorvLLM:
_build_preprocessed_mm_inputs()— deserializes proto tensors, constructsMultiModalFieldConfig, buildsis_embedmaskMultiModalKwargsItems.from_hf_inputs()— constructs MM inputs fromBatchFeature+ field configsTensorRT-LLM:
tensorrt_llm/llmapi/llm.py:489-494— the decode-and-reprocess path (tokenizer.decode()→ HF AutoProcessor)tensorrt_llm/grpc/grpc_servicer.py— multimodal image extraction from protoHF AutoProcessor internals (Qwen2VLProcessor as example)
The HF
AutoProcessor.__call__()does 3 things:self.image_processor(images)→ pixel_values + image_grid_thw (CPU preprocessing: resize, normalize)<|image_pad|>with N copies per imageself.tokenizer(expanded_text)→ input_idsIt does NOT run the vision encoder (ViT). The ViT runs during
model.forward().FastImageProcessor
HuggingFace has
FastImageProcessor(torchvision-based) that can run on GPU. SGLang uses it withdevice="cuda"by default. vLLM usesuse_fast=Truebut defaults to CPU. This is orthogonal to the main issue — the savings come from skipping redundant preprocessing entirely, not from where resize/normalize runs.