From Image Bytes to the First Token: Tracing a Qwen3-VL Request Through SGLang
Introduction
What actually happens inside SGLang when we send an image along with a text prompt? How does the request travel from raw image bytes to the first generated token?
Unlike text-only inference, multimodal inference introduces additional steps: decoding and preprocessing images, constructing visual token positions, transferring multimodal features between processes, running the vision encoder, and integrating visual embeddings into the language model.
In this article, I'll trace a Qwen3-VL-4B-Instruct request end to end through SGLang v0.5.23, from the OpenAI-compatible HTTP API to the first generated token. Along the way, we'll follow how image data is transformed, how it moves through the system, and how the scheduler determines which computations are necessary.
Rather than simply tracing the request through the codebase, I designed and ran a series of controlled experiments on a single NVIDIA A100 80GB GPU to understand how SGLang behaves in practice.
Some results were less obvious than the execution diagram suggested. Changing the order of an image and a question caused Radix prefix reuse to drop from 264/289 to 6/289 tokens, but with the VLM embedding cache enabled, median TTFT barely changed. In another experiment, switching the image-feature transport from cpu to cuda_ipc saved 15.3 ms for repeated-image requests, even though 283/284 input tokens were already matched in the Radix cache.
These observations raised a broader question: which parts of a multimodal request are actually reused, and which parts still execute before the first token is returned? To investigate, we'll connect the source code with runtime measurements, looking at what each component does, what data it passes onward, and where latency can remain even when expensive model computations are skipped.
This is the first article in a series investigating multimodal inference performance in SGLang. The following articles will examine cache reuse in more detail, benchmark image resolution and concurrency, and investigate bottlenecks through profiling and controlled comparisons.
The goal is not only to understand SGLang's multimodal execution path, but also to establish a reproducible, measurement-driven approach to studying inference systems.
Experiment Setup
To make the investigation reproducible, I pinned both the SGLang implementation and the model revision.
| Component | Configuration |
|---|---|
| Model | Qwen/Qwen3-VL-4B-Instruct |
| Model revision | ebb281ec70b05090aa6165b016eac8ec08e71b17 |
| SGLang | v0.5.23 |
| SGLang commit | 297e54fd105e974cdf401e5b4d20253a22c16f90 |
| Container image | lmsysorg/sglang:v0.5.23-cu130 |
| GPU | NVIDIA A100 SXM4 80GB |
| Tensor parallelism | 1 |
| NVIDIA driver | 595.91.07 |
| CUDA runtime | 13.0 (PyTorch build) |
| Python / PyTorch | 3.12.16 / 2.14.1+cu130 |
| Input | 512 × 512 PNG image + text prompt |
| API | /v1/chat/completions |
| Streaming | Enabled |
| Measured concurrency | 1 |
For the main example, I generated a synthetic PNG containing a red circle and the text SGLang 42. The request asks:
What shape and color is in the image, and what text is written? Answer briefly.
The corresponding API payload looks like this (with the base64 data abbreviated):
body = {
"model": "Qwen/Qwen3-VL-4B-Instruct",
"messages": [{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,..."},
},
{
"type": "text",
"text": (
"What shape and color is in the image, "
"and what text is written? Answer briefly."
),
},
],
}],
"temperature": 0,
"max_tokens": 32,
"stream": True,
"stream_options": {"include_usage": True},
}
The image comes before the text question. This ordering becomes important when we later discuss prefix caching.
We'll follow two execution scenarios:
- A new image request: An image without a reusable image-specific KV prefix, requiring vision processing and language-model prefill.
- A repeated image request: The same image and prompt sent again, allowing SGLang to reuse most of the KV prefix.
The first scenario reveals the full multimodal execution path. The second shows which operations can be skipped and which still need to run.
Beyond reading the code, I collected client-side TTFT, token usage, cached-prefix information, and server-side request timing statistics. For controlled comparisons, I ran independent benchmark repetitions rather than relying on one request.
Figure 1
One image + text request, end to end
1. Client
- (current step)
Click any stage to jump to it.
image_url data URI plus a text question in the same content array.SRC (confirmed from pinned source) Stage order follows SGLang 297e54f (v0.5.23) source; conceptual, not drawn to time scale. GPU image preprocessing (resize/normalize) and the Vision Encoder are different GPU work. The Vision Encoder may be skipped via Radix or the VLM embedding cache.
Text version: all steps
- Client (
POST /v1/chat/completions): The client streams one OpenAI-style chat request: animage_urldata URI plus a text question in the samecontentarray. - OpenAI serving (
_convert_to_internal_request()):openai_v1_chat_completions()hands off toOpenAIServingChat, which splits the image reference from the template messages and builds aGenerateReqInput(in a thread executor). - TokenizerManager (
_tokenize_one_request()): The request contains media, and Qwen-VL generatesinput_idsfrom the raw prompt, so final token IDs are left to the multimodal processor. - Qwen-VL processor (
process_mm_data_async()): Decode and hash the image, resize/normalize it on the GPU (fast image processor), and expand the one<|image_pad|>into the image's positions. - Scheduler + Radix (
match_prefix()): The tokenized request and pixel features cross into the scheduler process. Radix matches the padded IDs (with media identity) to find a reusable KV prefix. - Vision Encoder (
Qwen3-VL ViT): If the image positions aren't already covered by cached KV (and no embedding-cache hit), the ViT turnspixel_valuesinto visual embeddings. - LLM prefill (
embed_mm_inputs() → forward): Visual embeddings fill the image positions of the input embeddings. The LLM prefills the uncached tokens, writes KV, and samples the first output token. - Detokenizer (
DetokenizerManager): The token ID goes to the detokenizer process, which turns it into text. - First streamed content (
data: {…delta…}): Back through TokenizerManager, the HTTP stream emits the first non-empty content chunk. Client-observed TTFT ends here.
Tip: focus the figure and use ← → to step.
From HTTP to Multimodal Preprocessing
The Request Enters SGLang
Our journey begins at SGLang's OpenAI-compatible /v1/chat/completions endpoint.
The HTTP server receives a ChatCompletionRequest and passes it to the OpenAI serving layer. OpenAIServingBase.handle_request() validates the request, while OpenAIServingChat._convert_to_internal_request() constructs the representation passed to TokenizerManager.
Figure 2
From the HTTP endpoint to TokenizerManager
1. openai_v1_chat_completions()
- Client
- Serving (this figure)
- Tokenizer (this figure)
- MM processor
- Scheduler
- ViT
- Prefill
- Detok
- Stream
openai_v1_chat_completions() entrypoints/http_server.py ↗
- in
- HTTP POST /v1/chat/completions (JSON body)
- out
ChatCompletionRequest(validated Pydantic model)
openai_serving_chat.handle_request().SRC (confirmed from pinned source) Function names and line numbers checked against SGLang 297e54f; links are pinned-commit permalinks. Click any node to see its input/output types and source link.
Text version: all steps
- openai_v1_chat_completions() (entrypoints/http_server.py): in HTTP POST /v1/chat/completions (JSON body); out
ChatCompletionRequest(validated Pydantic model). FastAPI route. Parses and validates the body, then awaitsopenai_serving_chat.handle_request(). - OpenAIServingBase.handle_request() (entrypoints/openai/serving_base.py): in
ChatCompletionRequest+ rawRequest; out calls the converter in a thread executor. Records the received time, validates, then runs_convert_to_internal_requestvialoop.run_in_executor(), so template rendering doesn't block the HTTP event loop. - OpenAIServingChat._convert_to_internal_request() (entrypoints/openai/serving_chat.py): in
ChatCompletionRequest(messages withimage_url+ text); out (GenerateReqInput,ChatCompletionRequest). Splits images from text, renders the chat template (one<|image_pad|>), and buildsGenerateReqInput(text=…, image_data=…). Figure 3 zooms in here. - GenerateReqInput (managers/io_struct.py): in fields set by the converter; out
text,image_data,sampling_params,stream;input_idsstill unset. The internal request object. The image is still an unprocessed reference (a data URI), not pixels. - TokenizerManager.generate_request() (managers/tokenizer_manager.py): in
GenerateReqInput; outTokenizedGenerateReqInput→ scheduler (ZMQ)._tokenize_one_request()seescontains_mm_input()and hands the prompt + image toprocess_mm_data_async();_send_one_request()then dispatches to the scheduler.
Tip: focus the figure and use ← → to step.
One useful implementation detail is that request conversion can run in a thread executor, so prompt preparation does not monopolize the HTTP event loop.
But the most important transformation happens when SGLang processes the message content.
The original OpenAI request contains an image URL and a text question in the same messages array. During conversion, SGLang separates them:
- The image data URI is preserved in an
ImageDataobject. - The chat template receives an image marker alongside the text, rather than the full base64 payload.
Figure 3
One content array, two representations
One OpenAI content array
- Client
- Serving (this figure)
- Tokenizer
- MM processor
- Scheduler
- ViT
- Prefill
- Detok
- Stream
HTTP request · messages[0].content
[
{"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0…"}},
{"type": "text", "text": "What shape and color…?"}
]ImageData( url="data:image/png;base64,…" ) # kept as-is · not decoded yet
[{"type": "image"},
{"type": "text", "text": "What shape…?"}]<|im_start|>user
<|vision_start|><|image_pad|><|vision_end|>What shape…?<|im_end|>
<|im_start|>assistantGenerateReqInput
GenerateReqInput( text="<|im_start|>user\n<|vision_start|><|image_pad|>…", image_data=[ImageData(url="data:image/png;base64,…")], input_ids=None, # filled later by the multimodal processor )
messages[0].content array.Shapes follow serving_chat.py and jinja_template_utils.py at 297e54f; base64 is abbreviated. The rendered prompt is derived from the chat template, not captured from a runtime log.
Text version: all steps
- One OpenAI content array. The HTTP request holds the image and the question side by side in one
messages[0].contentarray. - Split into two branches. Conversion splits it: the data URI is kept as
ImageData(url=…), still not decoded; the template message keeps only a lightweight{"type":"image"}next to the text. - Chat template renders one marker. The Qwen chat template turns
{"type":"image"}into exactly one<|image_pad|>between the vision start/end tokens. No base64 travels through the template. - Merge into GenerateReqInput. Both branches meet in
GenerateReqInput: the rendered prompt intext, the raw image reference inimage_data, andinput_ids=None. Token IDs are not final yet. - What the marker is (and isn't).
<|image_pad|>says where the image goes. It is not the image bytes, not pixels, and not a semantic embedding. Those arrive on other paths, later.
Tip: focus the figure and use ← → to step.
For Qwen3-VL, the rendered prompt contains a single <|image_pad|> surrounded by vision markers:
<|im_start|>user
<|vision_start|><|image_pad|><|vision_end|>What shape and color is in the image, and what text is written? Answer briefly.<|im_end|>
<|im_start|>assistant
This prompt is derived from the pinned chat template and source code, rather than directly captured from the runtime prompt log.
At this point, the image has not yet been decoded into pixel tensors, and the placeholder does not contain visual information. It only indicates where the image belongs in the conversation.
SGLang then constructs GenerateReqInput, which carries the rendered prompt, image data, sampling parameters, and streaming settings.
Why TokenizerManager Does Not Finish Tokenization Yet
There is a subtle distinction between two checks:
model_config.is_multimodal: Does the loaded model support multimodal inputs?contains_mm_input(): Does this particular request contain image, video, or audio data?
A model can support multimodal inference while serving an entirely text-only request. The latter does not need to enter the image-processing path simply because the model happens to be Qwen3-VL.
For Qwen-VL, TokenizerManager._tokenize_one_request() also does something that may initially seem surprising: it does not finalize the multimodal prompt's token IDs itself.
Qwen-VL sets generates_input_ids_from_raw_prompt=True, so TokenizerManager passes the rendered text and image data to process_mm_data_async(). That processor can expand the image marker only after it knows the processed image shape and the required visual positions.
This is our first important distinction: the representation produced by the chat template is not yet the final representation consumed by the language model.
The template renderer also has an LRU cache over template-ready messages. Since the image payload is kept separate, the same surrounding text can produce the same template representation even when the image changes. The source supports this behavior; I did not measure the template-cache hit rate, so I do not attribute specific latency savings to it.
Relevant source:
Image Bytes Become Model Inputs
Next, the request reaches Qwen-VL's multimodal processor through process_mm_data_async().
The processor checks whether the media artifact path is applicable. For the image-only request in this experiment, Qwen3-VL can use this path even when cross-request preprocessing caching is disabled. A cache miss inside the artifact path is not the same thing as taking the separate uncached fallback path.
The preparation work can include:
- Decoding the base64 image payload and computing a content identity.
- Looking up a reusable preprocessing artifact when that cache is enabled.
- Decoding the PNG and preparing the pixel data when processing is necessary.
- Resizing and normalizing the image.
- Constructing the processed image tensors and the metadata needed downstream.
One detail surprised me during the source-code investigation: not all image preprocessing happens on the CPU.
In the pinned configuration, Qwen3-VL's fast image processor uses the GPU for resize and normalization. With the default cpu multimodal feature transport, the resulting pixel data is then copied back to CPU memory and made available to the scheduler through shared memory.
Figure 4
How image bytes become model inputs
Data URI → bytes · media identity
- Client
- Serving
- Tokenizer
- MM processor (this figure)
- Scheduler
- ViT
- Prefill
- Detok
- Stream
- CPU
Data URI → bytes · media identity
base64 decode · hash
Artifact path · eligibility + cache lookup
media_artifacts
→ miss: keep processing (still on the artifact path)
- CPU
Decode PNG (as required)
PIL / image loader
- GPU
Fast image processor: resize + normalize not the ViT
Qwen2VLImageProcessorFast
Outputs for the request
mm_items · input_ids · mrope_positions
pixel_valuesnumeric pixels, not embeddingsimage_grid_thwprocessed gridinput_idsexpanded image slotsmrope_positionsM-RoPE positions- GPU → CPU
.cpu() → CPU shared memory
_send_one_request() · --mm-feature-transport
→ GPU-to-CPU copy, then shared memory to the scheduler
data:image/png;base64,… string becomes raw bytes, and the media path computes an identity (hash) for this image in the main process.SRC (confirmed from pinned source) Drawn from qwen_vl.py and media_artifacts/base.py at 297e54f. The hit/miss switch shows control flow only: no preprocessing-cache hit counter was collected, so no hit counts are claimed. This cache was OFF in the P5 runs. The separate uncached fallback (for inputs not eligible for the artifact path) is not drawn.
Text version: all steps
- Data URI → bytes · media identity [CPU]: The
data:image/png;base64,…string becomes raw bytes, and the media path computes an identity (hash) for this image in the main process. - Artifact path · eligibility + cache lookup [—]: For this image-only request, Qwen3-VL takes the media-artifact path, even with cross-request preprocessing caching OFF. A miss (or a disabled cache) stays on this path; it is not the separate uncached fallback.
- Decode PNG (as required) [CPU] (can be skipped on an artifact-cache hit): On a miss, the PNG is decoded into an image the processor can use.
- Fast image processor: resize + normalize [GPU] (can be skipped on an artifact-cache hit): Resize and normalization run on the GPU. This is image preprocessing on the GPU, not the model's Vision Encoder.
- Outputs for the request [—]: The processor returns
mm_items(pixel_values,image_grid_thw, offsets), the expanded token IDs, andmrope_positions. Still no visual embeddings. - Hand-off to the scheduler · cpu: With
cputransport (the default here), the GPU-prepared pixels are copied back with.cpu()and handed over through CPU shared memory. This copy is transport, not the Vision Encoder. - Hand-off to the scheduler · cuda_ipc: With
cuda_ipc, the pixels are copied into a GPU-resident IPC pool and only a handle crosses the process boundary. That avoids the CPU round trip, but it is not zero-copy (Figure 10).
Tip: focus the figure and use ← → to step.
This is an important distinction: GPU image preprocessing is not the same as running the Vision Encoder. Preprocessing prepares the numerical inputs; the Vision Encoder later computes learned visual features.
The multimodal processor produces information including input_ids, mm_items containing image features and metadata, the image grid information, and position information such as mrope_positions. These are passed onward to the scheduler.
Relevant source:
Two Data Paths: Token IDs and Image Tensors
At this point, an important question arises: if the prompt already contains image positions, where is the actual image information?
The answer is that SGLang carries two kinds of information through the request.
The Token and Identity Path
The text prompt contains token IDs, including positions reserved for the image.
Before multimodal processing, the rendered prompt contains a single <|image_pad|> placeholder. After processing, that placeholder expands into the number of positions required by the processed image.
Figure 5
One <|image_pad|>, many visual positions
Before: one marker
- Client
- Serving
- Tokenizer
- MM processor (this figure)
- Scheduler
- ViT
- Prefill
- Detok
- Stream
Token sequence before expansion
[chat prefix] [ONE image_pad] [question]. One token cannot hold a whole image's worth of positions.DERIVED (derived, not logged directly) Derived from model configuration, not a directly logged image_grid_thw. Patch 16 and merge 2 come from the retained processor config; the 256 / ~28 split is a derivation. The 4-token prefix is the Radix match for a different image RUN (measured in the experiment runs); 284 is the server-recorded prompt-token count RUN (measured in the experiment runs). The grid is schematic; not every 512 px image yields 256 positions.
Text version: all steps
- Before: one marker. After the chat template, the sequence is
[chat prefix] [ONE image_pad] [question]. One token cannot hold a whole image's worth of positions. - Processed image → 32 × 32 patches. If the square image stays 512 px after processing, a patch size of 16 gives a 32 × 32 patch grid. Dynamic resizing can change this for other images.
- 2 × 2 merge → 256 positions. Spatial merging combines each 2 × 2 block: 16 × 16 = 256 positions in the language model's sequence (derived from config).
- After: many image slots. The marker becomes
[chat prefix] [256 image slots] [question]. The server recorded 284 prompt tokens; ~28 text/template + 256 image is a derivation. - Slots ≠ pixels ≠ embeddings. These 256 slots are empty seats. No visual embedding exists yet; the Vision Encoder produces them later from
pixel_values.
Tip: focus the figure and use ← → to step.
For our 512 × 512 synthetic image, the server reported 284 prompt tokens. The model configuration and recorded totals are consistent with approximately 28 text/template tokens and 256 visual positions.
For a simple square image that remains 512 px after processing, patch size 16 yields a 32 × 32 grid; a 2 × 2 spatial merge yields 16 × 16 = 256 visual positions. This is an illustration of the configuration, not a rule that raw image width always determines visual token count. The exact runtime image_grid_thw tensor was not captured in this trace, and Qwen3-VL can perform dynamic image processing.
These visual positions tell the system where computed image features will eventually be inserted. They are not the features themselves.
The Image Tensor Path
Meanwhile, the numerical image data follows another path: image bytes become decoded and normalized pixel data, then enter the Vision Encoder when visual computation is needed.
Figure 6
Two tracks: positions & identity, pixels & embeddings
Two coordinated tracks
- Client
- Serving
- Tokenizer
- MM processor (this figure)
- Scheduler
- ViT (this figure)
- Prefill (this figure)
- Detok
- Stream
Padded input_ids
image slots = pad values from the image hash
Positions
mrope_positions
Safe Radix matching
key includes which image
pixel_values + image_grid_thw
numeric pixels, not embeddings
Vision Encoder GPU
patch embed → ViT → merge
Visual embeddings
one vector per image slot
embed_mm_inputs() → LLM input embeddings
visual embeddings fill the image positions marked by track A
SRC (confirmed from pinned source) Drawn from qwen_vl.py, req_types.py, mm_utils.py at 297e54f. The red/blue images are illustrative; strips are not to scale. The negative control supports correctness for the tested case only, not for every multimodal input.
Text version: all steps
- Two coordinated tracks. After preprocessing, the request carries two tracks: token / identity (positions, padded IDs) and media tensors (pixels + grid).
- Track A · IDs carry media identity. Image slots in the padded IDs are derived from the image's identity, not one generic placeholder. Radix matches on these IDs, so the prefix key includes which image it is.
- Track B · pixels → Vision Encoder.
pixel_valuesandimage_grid_thwgo through the Vision Encoder on the GPU (patch embedding, ViT blocks, spatial merge) to produce visual embeddings. - Tracks meet at embed_mm_inputs().
embed_mm_inputs()places the visual embeddings into the image positions of the LLM's input embeddings. Only now do the slots hold image content. - Same layout, different image. Two images can produce the same slot layout. Because the IDs carry identity, a different image diverges at its first slot: Radix reuses only the 4-token prefix, never the other image's KV.
Tip: focus the figure and use ← → to step.
Three terms should not be confused:
- Image slots identify positions in the language model's input sequence.
pixel_valuescontains numerical image data prepared for vision computation.- Visual embeddings are learned representations computed by the Vision Encoder.
During the model's forward pass, SGLang inserts the computed visual embeddings at the corresponding positions in the language model's input embeddings.
How Does the KV Cache Know Which Image Is Which?
There is a potential correctness problem here. Two different images can produce the same layout of image placeholders, even when their contents differ.
If prefix matching used only generic placeholder IDs, two requests could appear identical in the image region and incorrectly reuse each other's image-specific KV.
SGLang addresses this by incorporating media identity into the padded IDs used for prefix matching. These identity-aware IDs remain cache-matching information, not the actual visual embeddings.
In a negative-control experiment, I alternated structurally similar images with different contents. Across 300 requests, the image-specific KV prefix was not incorrectly reused, and all requests passed the shape-and-color correctness check. This supports the mechanism for the tested case; it is not a proof for every possible multimodal input.
Relevant source:
From Scheduler to the First Token
Once multimodal preprocessing is complete, the request is handed to SGLang's scheduler.
The scheduler manages request state, prefix KV reuse, and the work submitted to the model runner. Before executing the model, it checks how much of the prompt can reuse an existing attention-KV prefix.
Figure 7
A new image vs a repeated image and question
HTTP, template, image handling, transport · runs
- Client
- Serving
- Tokenizer
- MM processor
- Scheduler (this figure)
- ViT (this figure)
- Prefill (this figure)
- Detok
- Stream
Radix KV prefix (284 prompt tokens)
- runs
1. HTTP, template, image handling, transport
main process → scheduler
- runs
2. Radix prefix match
cached_tokens
- runs
3. Vision Encoder GPU
pixel_values → visual embeddings
- runs
4. LLM prefill GPU
extend = prompt − cached
- runs
5. First token streamed
detokenize → SSE
Three separate reuse mechanisms
Preprocessing cache
reuses processed image artifacts · hits not counted
VLM embedding cache
reuses ViT features, even without a Radix match · hits not counted
Radix KV cache
reuses the KV prefix · here 4/284
The 4/284 and 283/284 figures are server-reported cached_tokens RUN (measured in the experiment runs); prefill lengths are subtracted from them DERIVED (derived, not logged directly); which stages can be skipped follows the source SRC (confirmed from pinned source). No hit counters exist for the preprocessing or VLM embedding caches, so they are marked “not counted”, never drawn as hits. “New image” is measured on a rotation of different synthetic images; “same image” repeats one synthetic image.
Text version: all steps
- New image · HTTP, template, image handling, transport (runs): A new image goes through request handling, image preprocessing, and feature transport before the scheduler sees it.
- New image · Radix prefix match (4/284 matched): Only the 4-token chat prefix before the image matches. The image's identity differs, so matching stops at the first image slot.
- New image · Vision Encoder (needed): The image positions are not cached, so visual embeddings are needed (unless the separate VLM embedding cache supplies them).
- New image · LLM prefill (~280 tokens): Prefill covers roughly the 280 uncached tokens (284 − 4).
- New image · First token streamed (runs): The three cache layers are different mechanisms.
cached_tokensonly describes the Radix layer. - Same image + question · HTTP, template, image handling, transport (still runs): Even for a repeat, this work happens before the Radix lookup. A KV hit later cannot erase it.
- Same image + question · Radix prefix match (283/284 matched): Same image, same question: 283/284 tokens match, including all image positions.
- Same image + question · Vision Encoder (can be skipped): The image positions are inside the cached prefix, so the Vision Encoder can be unnecessary for this request.
- Same image + question · LLM prefill (~1 token): Prefill shrinks to about 1 token (284 − 283).
- Same image + question · First token streamed (runs): The three cache layers are different mechanisms.
cached_tokensonly describes the Radix layer.
Tip: focus the figure and use ← → to step.
Prefix Matching Before Model Execution
For different images with the same question, my experiment recorded only 4/284 cached tokens: the common prompt prefix before the image. Repeating the same image and question produced 283/284 cached tokens.
These numbers describe Radix KV-prefix reuse, not whether preprocessing or the VLM embedding cache hit.
This distinction matters because image preprocessing occurs before the scheduler's Radix match. Even a near-complete KV hit does not retroactively undo the work that has already happened upstream.
The Vision Encoder and Embedding Injection
For a request that needs new visual computation, the model runner processes the prepared image with Qwen3-VL's Vision Encoder.
The Vision Encoder applies patch embedding, Vision Transformer blocks, and spatial merging to produce visual embeddings. The multimodal embedding routine then places those features at the corresponding image positions in the language model's input embeddings.
The language model processes the combined text and visual embeddings during prefill, writes attention KV, and generates the next-token logits. From there, the serving system generates and streams the first output token.
A repeated request whose image positions are covered by reusable KV may not need to run the Vision Encoder again. Separately, the VLM embedding cache can reuse vision features even when Radix's prefix does not match. These two mechanisms skip different work.
Does Prompt Order Actually Matter?
I tested the same image with 100 changing questions, comparing image-first and question-first orders. The difference in Radix prefix matching was dramatic:
| P1b scenario | Image before changing question | Changing question before image |
|---|---|---|
| Radix cached prefix | 264/289 tokens | 6/289 tokens |
| TTFT p50, VLM embedding cache enabled | 56.0 ms | 54.4 ms |
| TTFT p50, VLM embedding cache disabled | 55.4 ms | 74.4 ms |
Figure 8
Prompt order: the prefix vanishes, TTFT barely moves
Two controls, one change
- Client
- Serving
- Tokenizer
- MM processor
- Scheduler (this figure)
- ViT (this figure)
- Prefill
- Detok
- Stream
A · image → changing question
264/289 matched RUN (measured in the experiment runs)
B · changing question → image
6/289 matched RUN (measured in the experiment runs)
Median TTFT (measured, not animation time) RUN (measured in the experiment runs)
Δ 1.6 ms: similar in these runs; no statistically meaningful speedup is claimed.
A100 SXM4 80GB 4B 512 px concurrency 1 3 independent runs
RUN (measured in the experiment runs) Context: A100 SXM4 80GB, Qwen3-VL-4B-Instruct, 512 px PNG, concurrency 1, 3 independent runs per configuration (medians). TTFT bars are measured medians across runs, not animation timing. In this experiment (P1b) each “changing question” is the same question with a Q000.–Q099. prefix, and the image is a different synthetic image from the red-circle example, which is why the prompt is 289 tokens rather than 284. Segment widths in the request strips are schematic. Results may not hold for other GPUs, models, prompt lengths, or concurrency.
Text version: all steps
- Two controls, one change. Same image, 100 changing questions. A puts the image first; B puts the changing question first. Only the order differs.
- Radix prefix reuse. A shares everything up to the changing question: 264/289 matched. B diverges before the image: 6/289.
- TTFT · VLM embedding cache ON (default). Yet with the default embedding cache ON, median TTFT was 56.0 ms vs 54.4 ms: similar in these runs. This is not evidence that text-first is faster.
- TTFT · VLM embedding cache OFF. Turn the embedding cache OFF and the gap appears: 55.4 ms vs 74.4 ms. Text-first is about 19 ms slower.
- Takeaway: prompt order changes Radix-prefix eligibility, but the latency effect depends on other caches and the tested workload.
Tip: focus the figure and use ← → to step.
Moving the changing question before the image caused the requests to diverge earlier, so Radix could no longer reuse the image's KV prefix.
Yet with the embedding cache enabled, median TTFT did not show a corresponding slowdown in this experiment. Once that cache was disabled, the question-first scenario was about 19 ms slower. The comparison is consistent with the embedding cache avoiding repeated vision computation when Radix reuse is unavailable.
So prompt order does affect prefix reuse. Whether it affects end-to-end TTFT depends on other caches, hardware, prompt length, and workload conditions. This is why cached_tokens alone is not enough to tell us how much actual vision work occurred.
The First Token Reaches the Client
After the required forward computation, SGLang generates an output token, handles the output/detokenization path, and streams the content back to the client.
This is the endpoint of our main trace.
TTFT includes much more than GPU model execution. It also reflects request handling, multimodal preprocessing, inter-process communication, scheduling, and output delivery.
Relevant source:
What the Runtime Measurements Revealed
Following the source code gave me a model of how the request should execute. The experiments helped establish which parts of that model mattered for actual latency.
Most of the Extra TTFT Occurred Before Scheduling
With SGLang's request-time statistics enabled, I compared repeated text-only and image requests and divided client-observed TTFT into two coarse intervals: before entering the scheduler queue and after that boundary.
| Median latency | Text-only | Image + Text | Difference |
|---|---|---|---|
| TTFT | 30.6 ms | 52.9 ms | +22.3 ms |
| Before scheduler queue | 3.5 ms | 22.1 ms | +18.6 ms |
| After scheduler queue entry | 27.0 ms | 28.7 ms | +1.7 ms |
Figure 9
Splitting TTFT at the scheduler queue
Client-observed TTFT
- Client (this figure)
- Serving (this figure)
- Tokenizer (this figure)
- MM processor (this figure)
- Scheduler
- ViT
- Prefill
- Detok
- Stream
RUN (measured in the experiment runs) Medians in ms from interleaved requests within one server process, in a separate run with request time-stats logging. Differences are subtractions of medians DERIVED (derived, not logged directly). Because the logging setup differs, compare gaps, not absolutes, and don't overlay these with the transport A/B. “Before queue” includes HTTP, media, preprocessing, and transport, not pure function-level main-process time.
Text version: all steps
- Client-observed TTFT. Median client TTFT: text-only 30.6 ms, image + text 52.9 ms. Where does the difference live?
- Before the scheduler queue. Client send → scheduler queue: 3.5 ms vs 22.1 ms. The medians differ by 18.6 ms. This region includes HTTP, media handling, preprocessing, and transport.
- After the scheduler queue. Scheduler queue → first content: 27.0 ms vs 28.7 ms. Only 1.7 ms apart.
- Medians don't add. 18.6 + 1.7 ≠ 22.3. Each row is a separate median over the same requests, so they are compared side by side, not stacked into one timeline.
- What this does and doesn't show. It points to a region (pre-scheduler), not a single slow function. This run used extra time-stats logging, so don't overlay its absolute values on the transport A/B.
Tip: focus the figure and use ← → to step.
Most of the measured extra TTFT appeared before the scheduler queue boundary. That interval includes request handling, image preparation, transport, and related overhead. It narrows down where to investigate, but it does not measure individual function timings.
The time-stats configuration also differs from some earlier experiments, so I use this comparison to locate the overhead rather than combining its absolute latencies with other runs.
Cache Reuse Is Not Just One Mechanism
I then compared preprocessing-cache and Radix-cache configurations. For repeated images, Radix reuse lowered median TTFT by roughly 13–16 ms across the tested configurations, while preprocessing caching saved approximately 4–10 ms, depending on the other cache settings.
The prompt-order experiment provided a second insight: Radix can lose almost its entire reusable prefix while the VLM embedding cache still avoids rerunning the Vision Encoder.
The broader lesson is that preprocessing artifacts, vision embeddings, and language-model KV are different reusable objects. A hit at one layer does not imply a hit at another. Their interactions deserve their own controlled benchmark, which I'll cover in the next article.
The Feature Transport Path Was a Major Contributor
The source code provided another clue: in the cpu transport path, GPU-prepared pixel data is copied back to CPU memory and transferred through shared memory before the scheduler uses it.
I compared that implementation with cuda_ipc, which uses a GPU-resident buffer and CUDA IPC handles for inter-process feature access. The A/B experiments disabled preprocessing caching and VLM embedding caching while keeping Radix available.
| Workload | cpu TTFT p50 | cuda_ipc TTFT p50 | Improvement |
|---|---|---|---|
| Text-only | 29.5 ms | 28.6 ms | 0.9 ms |
| New image | 68.1 ms | 51.3 ms | 16.8 ms |
| Repeated image | 50.1 ms | 34.8 ms | 15.3 ms |
Figure 10
Feature transport: cpu vs cuda_ipc
Pixels ready on the GPU · cpu
- Client
- Serving
- Tokenizer
- MM processor (this figure)
- Scheduler (this figure)
- ViT
- Prefill
- Detok
- Stream
pixel_values
fast-processor output
.cpu() → shared memory
GPU → CPU
.to(cuda) for the ViT
if the ViT runs: CPU → GPU
read from shared memory
CPU tensor
SRC (confirmed from pinned source) Paths follow the pinned source. RUN (measured in the experiment runs) Numbers are an implementation-level A/B (P5): A100 SXM4 80GB, 512 px PNG, concurrency 1; preprocessing and VLM embedding caches OFF, Radix ON; server restarted per cell. Not per-function profiler timings, and not a reproduction of the separate CUDA IPC handle-cache optimization. Larger images, contention, and pool exhaustion are untested.
Text version: all steps
- cpu · Pixels ready on the GPU: The fast image processor has resized/normalized the image on the GPU, inside the main process. The scheduler is a different process.
- cuda_ipc · Pixels ready on the GPU: Same starting point: processed pixels live on the GPU in the main process.
- cpu · Prepare for another process:
cpu: the tensor is copied back with.cpu()and placed in CPU shared memory: a GPU → CPU trip. - cuda_ipc · Prepare for another process:
cuda_ipc: the tensor is copied into a preallocated, GPU-resident 1 GiB IPC pool. That is still a copy; this is not “zero-copy”. - cpu · Cross the process boundary: The scheduler reads the tensor from shared memory: the data is now on the CPU side of the scheduler process.
- cuda_ipc · Cross the process boundary: Only a small IPC handle crosses the boundary, not the pixels.
- cpu · Back on the GPU for the ViT: If the Vision Encoder needs it, the tensor goes CPU → GPU again. Round trip: GPU → CPU → GPU.
- cuda_ipc · Back on the GPU for the ViT: The scheduler opens the handle and accesses the mapped GPU memory, possibly lazily (a repeated image that skips the ViT may never touch it).
- Text-only control: cpu 29.5 ms → cuda_ipc 28.6 ms (−0.9 ms)
- New image each request: cpu 68.1 ms → cuda_ipc 51.3 ms (−16.8 ms)
- Same image + question: cpu 50.1 ms → cuda_ipc 34.8 ms (−15.3 ms)
cuda_ipcreserves ~1 GiB of GPU memory, and the scheduler's GPU allocation came out ~2.2 GiB smaller, leaving less room for static/KV memory.
Tip: focus the figure and use ← → to step.
For repeated-image requests, the image-versus-text TTFT gap fell from 20.6 ms to 6.2 ms. The new-image workload also improved, while the text-only baseline was approximately unchanged.
These controlled comparisons support feature transport as a major contributor to latency under the tested conditions. However, they do not identify the exact time spent in each .cpu() copy or shared-memory operation. That would require more granular profiling.
There is a trade-off. The CUDA IPC configuration reserved a 1 GiB GPU buffer, and the observed scheduler memory allocation was about 2.2 GiB smaller, leaving less room for its static/KV allocation. These results are from 512 px synthetic images at concurrency 1; larger images and higher concurrency may change the balance.
A Lesson in Measurement
There was another important finding during this investigation, unrelated to any individual SGLang component.
My initial comparison suggested image requests were 70–80 ms slower than text-only requests. However, the comparison crossed different server processes and did not adequately control the conditions.
After revising the measurement script and running interleaved comparisons within the same server process, the estimated difference became smaller and more consistent with the request-time statistics.
I also corrected a measurement assumption: streaming content chunks are not necessarily equivalent to model tokens. The updated benchmark records server token usage and content-chunk timing separately, including the case where a stop-generated EOS token counts toward completion usage without producing a normal text chunk.
This experience changed how I approached the investigation: instead of accepting the first latency number, I made sure I understood what each metric actually represented.
Tracing code tells us what may happen. Controlled experiments tell us what changes performance under the tested conditions.
Takeaways and What's Next
Tracing a Qwen3-VL request through SGLang revealed several lessons about multimodal serving.
Image placeholders are not image embeddings. The chat template records where an image belongs. The multimodal processor prepares data and expands positions; the Vision Encoder produces the learned features that the language model consumes.
KV-cache reuse does not eliminate every image-related operation. Even when most of the prefix can be reused, media handling, preprocessing, and feature transport can remain on the critical path.
Performance bottlenecks are not always where the expensive model computations are. In this experiment, changing feature transport reduced median TTFT for repeated-image requests by about 15 ms, showing why measuring the full request path matters.
Reproducibility and Limitations
The investigation uses pinned model and SGLang revisions, a single NVIDIA A100, controlled synthetic images, and independent experimental runs. Its conclusions should be interpreted within those conditions:
- The main reported workloads used 512 × 512 images at concurrency 1.
- Synthetic shapes and text are useful controls, but are not representative of production photographs or documents.
- Some cache behavior is inferred from controlled comparisons and source code rather than directly exported hit/miss counters.
- The TTFT and request-time data do not constitute a complete per-function or GPU-kernel profile.
- Exact OCR accuracy on this workload was poor, so latency measurements and answer-correctness checks were kept separate. Zero request errors does not mean zero answer errors.
- The NVIDIA driver, CUDA runtime, Python, and PyTorch versions listed above come from the preserved environment manifest rather than being inferred from the container tag; raw
nvidia-smiandcheck_envoutput was not retained.
The main source-code entry points for reproducing this trace are:
Next in the Series
- Understanding Multimodal Cache Reuse in SGLang: Preprocessing, Vision Embeddings, and Radix KV — isolate which work each cache actually saves.
- Benchmarking Qwen3-VL Serving: Image Resolution, Concurrency, and Tail Latency — understand how workload shape changes the bottlenecks.
- Investigating Multimodal Serving Bottlenecks: Feature Transport, Profiling, and Trade-offs — measure deeper stages and test whether improvements hold under load.
Understanding the request path is the first step. Measuring what happens along that path is what turns a code trace into a performance investigation.