Skip to content
This repository was archived by the owner on May 5, 2026. It is now read-only.

Add OpenVLA (Vision-Language-Action) model support#17

Merged
mgehre-amd merged 7 commits into
matthias.awq_gemvfrom
mkorhone/merge_openvla_pr
Mar 27, 2026
Merged

Add OpenVLA (Vision-Language-Action) model support#17
mgehre-amd merged 7 commits into
matthias.awq_gemvfrom
mkorhone/merge_openvla_pr

Conversation

@mkorhone

@mkorhone mkorhone commented Mar 19, 2026

Copy link
Copy Markdown
Collaborator

Add OpenVLA (Vision-Language-Action) model support

Summary

There are 3 separate commits, and I'd recommend reviewing each commit independently.

  1. Add OpenVLA (openvla/openvla-7b) model support to vLLM, based on upstream PR by Jiafan Yu: [Model] Add OpenVLA model support vllm-project/vllm#32390
  2. Fix offline inference: dtype cast for pixel_values (float32→bfloat16), use PromptIndexTargets.start() for image token insertion (BOS may be absent in chat API path), register OpenVLAConfig
  3. Support OpenVLA in HTTP serving and benchmarks: chat template fallback, non-streaming JSON response fallback, TTFT/ITL inference for non-streaming models, SSE \r\n normalization

Test Results (Strix Halo / gfx1151, ROCm 7.13 nightly)

All tests run on a single Radeon 8060S (gfx1151, 32 GiB VRAM), ROCm 7.13 nightly, PyTorch 2.10.0+rocm7.13.0a20260317.

OpenVLA performance tests

Each test processes unique 224x224 images one at a time (max_num_seqs=1, enable_prefix_caching=False, mm_processor_cache_gb=0). Warmup runs are excluded from measurement. MSE is computed against ground-truth action values from the BridgeData V2 dataset.

Test Mode Throughput TTFT TPOT E2E Latency Avg MSE
1a: Offline (eager) --enforce-eager 11.88 tok/s 194 ms 65.9 ms 589 ms 0.048
1b: Offline (CUDAGraph) default 11.81 tok/s 196 ms 66.2 ms 593 ms 0.048
2: HTTP serve (vllm-bench) vllm serve + vllm bench serve 197 ms 65.7 ms 591 ms

Notes:

  • Test 1a/1b throughput is aggregate (total output tokens / total wall time across all samples). Per-request E2E latency is consistent across all three tests (~590 ms for 7 action tokens).
  • Test 2 uses unique synthetic 224x224 images, --max-concurrency 1, --no-enable-prefix-caching. No batching or image pre-caching.

LLM / VLM performance regression tests

Baseline measured on origin/matthias.awq_gemv (commit 6dd38c17c) before any OpenVLA changes. Post-change measured on the final branch (daf4aed3f). No performance regression detected.

Model Metric Baseline Post-change Delta
Qwen/Qwen3-4B-AWQ (LLM) Prefill (tok/s) 1213.65 1209.28 −0.4%
Decode (tok/s) 72.9 72.9 0.0%
TTFT (ms) 105 106 +1%
E2E latency (ms) 1848 1847 −0.1%
Qwen/Qwen2-VL-2B-Instruct (VLM, --synthetic-mm) Prefill (tok/s) 220.98 220.30 −0.3%
Decode (tok/s) 67.8 67.8 0.0%
TTFT (ms) 579 581 +0.3%
E2E latency (ms) 2451 2455 +0.2%

Functional correctness

Generated output was verified to be byte-for-byte identical before and after the changes using deterministic prompts (temperature=0).

LLM (Qwen3-4B-AWQ): Sanity check with math prompts — identical responses.

Prompt Baseline Post-change Match
1+1= 2 is a 2 is a YES
2+3= 5, 5, YES

VLM (Qwen2-VL-2B-Instruct): Three image-description prompts using a deterministic synthetic test image (320x240 PNG with red rectangle, blue rectangle, yellow ellipse on gradient background). All responses identical.

Prompt Baseline Post-change Match
"Describe the shapes and colors you see in this image." "The image consists of three shapes: a red square, a yellow circle, and a blue square. The background is a gradient of colors, transitioning from dark blue at the top to a lighter blue at the bottom." (identical) YES
"How many distinct colored shapes are in this image?" "There are three distinct colored shapes in the image: a red square, a yellow circle, and a blue square." (identical) YES
"What color is the rectangle on the left side of the image?" "The rectangle on the left side of the image is red." (identical) YES

@mkorhone mkorhone force-pushed the mkorhone/merge_openvla_pr branch from daf4aed to c0ec2db Compare March 19, 2026 17:38
@mkorhone mkorhone requested review from eble-amd and mgehre-amd March 19, 2026 18:12
@mkorhone mkorhone marked this pull request as ready for review March 19, 2026 18:12
Comment thread vllm/model_executor/models/openvla.py
@mkorhone

Copy link
Copy Markdown
Collaborator Author

Details on the issue with 0 output tokens when using the HTTP server

Output Token / Benchmark Metrics Issue

OpenVLA emits 7 action tokens per request (vocabulary IDs 32000–32255) that the
standard Llama tokenizer decodes to empty strings or non-meaningful Unicode (e.g.
'ự红식么達터忠'). This caused cascading failures in the vLLM benchmark
infrastructure:

  • Streaming: delta.content was empty per SSE chunk, so no text accumulated.
  • completion_tokens: Could be 0 or missing in the stream.
  • Metrics: TTFT, ITL, and TPOT all computed as zero because they relied on
    receiving non-empty content chunks.

Fixes

1. Non-streaming JSON fallback in endpoint_request_func.py
When stream=True but the server returns a single non-SSE JSON body (as happens
with OpenVLA), the benchmark client now detects this and parses message.content
and usage.completion_tokens from the raw JSON. TTFT and ITL are synthesized from
total latency.
2. TTFT/ITL inference in serve.py (calculate_metrics)
When output_len > 0 but ttft == 0.0 and itl is empty (i.e. no per-token
timing was captured), the metrics calculation infers uniform per-token timing from
total latency:

if output_len > 0 and ttft == 0.0 and not itl_list:
    inferred_ttft = outputs[i].latency / output_len
    ttft = inferred_ttft
    itl_list = [inferred_ttft] * (output_len - 1)

Comment thread tests/models/multimodal/test_openvla_consistency.py Outdated
Comment thread vllm/benchmarks/lib/endpoint_request_func.py Outdated
Comment thread vllm/benchmarks/lib/endpoint_request_func.py Outdated
Comment thread vllm/benchmarks/serve.py Outdated
Comment thread vllm/benchmarks/lib/endpoint_request_func.py Outdated
Comment thread vllm/benchmarks/lib/endpoint_request_func.py Outdated
@mgehre-amd

Copy link
Copy Markdown
Owner

OpenVLA emits 7 action tokens per request (vocabulary IDs 32000–32255) that the
standard Llama tokenizer decodes to empty strings or non-meaningful Unicode

When does this become empty? non-meaningful unicode sounds fine to me, as long as the conversion from token id to unicode and back is lossless. I wonder whether vllm serve allows to skip the tokenizer decode, and just return the token_ids - in the same ways as test_openvla_consistency.py uses the token_ids from offline inference.

  • Streaming: delta.content was empty per SSE chunk, so no text accumulated.
    The connection to the previous point is not clear. How does "non-meaningful Unicode" lead to empty content?

I'm concerned about the changes under vllm/benchmarks because they can potentially affect other models, too.

@mkorhone mkorhone force-pushed the mkorhone/merge_openvla_pr branch from 6f384fb to b55cfd8 Compare March 26, 2026 01:01
@mkorhone

Copy link
Copy Markdown
Collaborator Author

OpenVLA emits 7 action tokens per request (vocabulary IDs 32000–32255) that the
standard Llama tokenizer decodes to empty strings or non-meaningful Unicode

When does this become empty? non-meaningful unicode sounds fine to me, as long as the conversion from token id to unicode and back is lossless. I wonder whether vllm serve allows to skip the tokenizer decode, and just return the token_ids - in the same ways as test_openvla_consistency.py uses the token_ids from offline inference.

  • Streaming: delta.content was empty per SSE chunk, so no text accumulated.
    The connection to the previous point is not clear. How does "non-meaningful Unicode" lead to empty content?

I'm concerned about the changes under vllm/benchmarks because they can potentially affect other models, too.

Great questions. Here's some clarification from Claude:

Empty vs non-meaningful Unicode: The Llama-2 tokenizer maps action token IDs (31744-31999) to Unicode characters like \u1ef1\u7ea2\uc2dd.... These decode to non-empty strings, but vLLM's SSE streaming was emitting empty delta.content for some chunks, which is a separate issue in the streaming pipeline. Since we removed the client-side workarounds, this is no longer papered over.

Returning token IDs instead of text: Yes, vLLM's offline API already returns token_ids directly (which is what our test uses). For vllm serve, the OpenAI-compatible API doesn't have a standard way to return raw token IDs instead of decoded text, but logprobs can be used to get them alongside the text.

Benchmark changes affecting other models: The remaining benchmark changes are:

  1. SSE \r\n normalization -- spec-compliance fix, no-op when \r absent
  2. Pre-formatted message passthrough -- only activates for list[dict] prompts from CustomMMDataset, no-op for string prompts
  3. Non-streaming path -- only activates when caller explicitly sets stream: false
  4. serve.py local variable extraction -- pure refactoring, no behavioral change
    All four are guarded by conditions that don't trigger for existing usage patterns. The Qwen3-4B-AWQ and Qwen2-VL-2B regression benchmarks confirm no performance or correctness changes.

@mkorhone

Copy link
Copy Markdown
Collaborator Author

I rebased this branch to the latest in our vllm fork, worked with Claude to address the code review comments and force pushed. Unfortunately, there was a regression in behaviour that I needed to fix, so some rework was required.

@mkorhone mkorhone force-pushed the mkorhone/merge_openvla_pr branch from b55cfd8 to 9f58caa Compare March 26, 2026 01:25
Comment thread vllm/benchmarks/serve.py
@mkorhone mkorhone force-pushed the mkorhone/merge_openvla_pr branch 4 times, most recently from a0f3634 to bfc4cc9 Compare March 27, 2026 00:20
PalmDr and others added 6 commits March 26, 2026 17:27
OpenVLA is a 7B VLA (Vision-Language-Action) model for robotic manipulation
that outputs discretized robot action tokens (7D: xyz, rpy, gripper).

Architecture:
- Vision: DINOv2 + SigLIP fused backbone via timm
- Projector: 3-layer MLP (2176 -> 8704 -> 4096 -> 4096)
- LLM: Llama-2-7B generating 7 action tokens (256 bins each)

Key implementation details:
- Custom 6-channel preprocessing in processor for exact HF matching
- Uses timm models with proper dtype/device handling
- Action tokens use vocabulary positions [32000, 32255]

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: Jiafan Yu <joyjfy@gmail.com>
- Add 'from err' to ImportError exception chain (B904)
- Remove unused imports (ClassVar, Final, MultiModalConfig, etc.)
- Sort imports with isort (I001)
- Fix line length violations by restructuring code
- Use X | None instead of Optional[X] (UP045)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: Jiafan Yu <joyjfy@gmail.com>
- BaseDummyInputsBuilder: vllm.multimodal.processing, not profiling
- set_default_torch_dtype: vllm.utils.torch_utils, not vllm.utils

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: Jiafan Yu <joyjfy@gmail.com>
Address reviewer feedback:
- Move device placement to _init_timm_models() instead of separate method
- Remove _move_timm_to_device() method
- Simplify load_weights() by removing separate device movement step

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: Jiafan Yu <joyjfy@gmail.com>
vLLM handles tensor placement automatically, so explicit .to() calls
are not needed in the forward method.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: Jiafan Yu <joyjfy@gmail.com>
Test compares vLLM output tokens against HuggingFace transformers reference
for 5 robot manipulation instructions. Expected result: 4/5 exact match (80%),
matching SGLang's achievement. Sample 3 fails due to low model confidence.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: Jiafan Yu <joyjfy@gmail.com>
@mkorhone mkorhone force-pushed the mkorhone/merge_openvla_pr branch from bfc4cc9 to 03bd0c3 Compare March 27, 2026 00:31
- Switch from PromptInsertion to PromptReplacement with <PAD> placeholder
  token so vLLM's multimodal processing correctly replaces the image token
  rather than inserting at a position index
- Cast pixel_values to vision backbone dtype/device (HF processor outputs
  float32, backbone expects bfloat16)
- Fix self.allowed_mm_limits -> self.info.allowed_mm_limits attribute access
- Register OpenVLA chat template and config for OpenAI-compatible API
- Use self.image_token_index for pad_token_id instead of hardcoded 32000
- Update test to use hardcoded reference tokens (avoids loading two 7B
  models simultaneously) and correct action token range (31744-31999)

Co-authored-by: Matthias Gehre <matthias.gehre@amd.com>
Made-with: Cursor
@mkorhone mkorhone force-pushed the mkorhone/merge_openvla_pr branch from 03bd0c3 to 2956137 Compare March 27, 2026 01:09
@mkorhone

mkorhone commented Mar 27, 2026

Copy link
Copy Markdown
Collaborator Author

I have removed all NFC refactors, and integrated Matthias's suggestions from #27 to vastly reduce the scope of changes. I am going to add additional comments here (generated with Claude Opus's help) to describe the rationale for all the changes in the commit I applied on top of commits listed in the upstream pull request: https://github.com/vllm-project/vllm/pull/32390/commits .

@mkorhone

mkorhone commented Mar 27, 2026

Copy link
Copy Markdown
Collaborator Author
  1. Why are we no longer comparing tokens against the HuggingFace reference in the openvla test PalmDr's commits added?

There are two reasons, one practical and one fundamental:

Practical: The HF model can't load alongside vLLM in the same process. OpenVLA is a 7B parameter model (~14 GiB in bfloat16). PalmDr's test loaded the HF model via AutoModelForVision2Seq.from_pretrained() and the vLLM LLM() in the same process. On our Strix Halo (32 GiB VRAM), there is not enough GPU memory for two copies. Even if memory weren't an issue, the HF remote code for OpenVLA requires transformers ~= 4.40.1 and specific timm patches. Our environment has transformers 4.57.6 and timm 1.0.x, which causes the HF model to output garbage (all tokens decode to 31872). PalmDr's test was almost certainly never executed end-to-end -- it was written based on assumed behavior rather than validated runs.

Fundamental: We verified correctness through a separate, validated pipeline. Our reference tokens were captured from a validated vLLM run that was independently cross-checked against:

  • HuggingFace transformers output (captured earlier in a compatible environment, documented on Confluence)
  • PyTorch reference script (rocm-scripts/test_openvla_pytorch.py)
  • BridgeData V2 ground-truth actions (MSE validation)

The test is now a regression test: it ensures that future vLLM changes don't break the known-good output. This is the standard pattern used by most vLLM model tests -- they compare against hardcoded reference values, not live HF inference.

If challenged: You can say: "The reference tokens were captured from a validated vLLM run whose output was cross-checked against HuggingFace transformers and BridgeData V2 ground truth. Loading the HF model at test time is impractical (requires ~28 GiB VRAM for two 7B models, plus specific transformers/timm version constraints). The hardcoded-reference pattern is used throughout vLLM's test suite for exactly this reason."

@mkorhone

mkorhone commented Mar 27, 2026

Copy link
Copy Markdown
Collaborator Author
  1. Are these tests using random noise images? Is that valid?

Yes, the test image is a seeded random 224x224 image (np.random.default_rng(seed=42)) -- this was PalmDr's original choice and we did not change it. It is valid for this purpose because:

The test is checking inference consistency, not action quality. OpenVLA is a fine-tuned Llama-2-7B model. Given any 224x224 image + instruction text, the model will produce 7 tokens in the action token range. The tokens represent discretized robot actions, but the model doesn't "know" the image is noise -- it processes whatever visual features the DINOv2+SigLIP backbone extracts and produces a deterministic output (temperature=0). What matters is that the same input always produces the same output.

Using a synthetic image is actually better for a regression test because:

  • It's deterministic and reproducible (seeded RNG, no filesystem dependency)
  • It doesn't require downloading/shipping dataset files
  • The test runs anywhere without external data dependencies

For real correctness validation (i.e., "does the model predict useful robot actions?"), we have the separate Bridge dataset performance test (test_openvla_vllm.py) which runs real robot images and computes MSE against ground-truth actions.

@mkorhone

mkorhone commented Mar 27, 2026

Copy link
Copy Markdown
Collaborator Author
  1. Why switch from PromptInsertion to PromptReplacement?

This was a bug in PalmDr's implementation that we discovered through debugging. Here is the full story:

PalmDr's code used PromptInsertion with PromptIndexTargets.prefix([bos_token_id]), which means "insert image tokens after the BOS token at the start of the prompt." This approach has two problems:

Problem 1 -- BOS token may be absent. When vLLM serves via the HTTP /v1/chat/completions API, the chat template tokenizes the prompt. Depending on the template and tokenizer configuration, BOS may not be prepended. If BOS is absent, PromptIndexTargets.prefix([bos_token_id]) fails to find a match and the image tokens are never inserted, resulting in text-only inference (no vision features).

Problem 2 -- PromptInsertion is the wrong paradigm for OpenVLA. The PromptInsertion approach inserts new tokens without replacing anything. But OpenVLA's actual design uses (token ID 32000) as an explicit placeholder in the prompt that should be replaced by the 256 vision feature tokens. The HF processor puts in the token sequence, and the model's embed_multimodal method replaces it with vision embeddings. PromptReplacement matches this design: it finds the token(s) in the prompt and replaces each with the 256 vision tokens.

The evidence:

  • PromptReplacement is used by ~70+ models in vLLM; PromptInsertion is used by only 3 (PaliGemma, Molmo, BLIP-2), all of which have genuinely different architectures where the image tokens are prepended rather than replacing a placeholder.
  • The OpenVLA tokenizer has as token 32000 (confirmed in tokenizer_config.json). The HF config has pad_token_id: 32000. The model expects in the sequence.
  • Using PromptReplacement with target=[image_token_id] finds the token in the prompt and replaces it with 256 vision tokens. This works consistently in both offline mode and HTTP serving.

If challenged: "PalmDr's PromptInsertion approach was position-dependent (required BOS token to be present) and didn't match OpenVLA's actual design, which uses as an explicit image placeholder. PromptReplacement is the standard pattern used by the vast majority of vLLM multimodal models and correctly replaces the placeholder regardless of how the prompt was tokenized."

@mkorhone

mkorhone commented Mar 27, 2026

Copy link
Copy Markdown
Collaborator Author
  1. Why self.info.allowed_mm_limits instead of self.allowed_mm_limits?

This was a straightforward AttributeError bug. PalmDr's code called self.allowed_mm_limits.get("image", 1) inside OpenVLAMultiModalProcessor._apply_hf_processor_missing(). But allowed_mm_limits is a @cached_property on BaseProcessingInfo (the self.info object), not on BaseMultiModalProcessor (which is self).

How the bug manifested: This code path is reached during vLLM's startup profiling phase, when the model processes dummy inputs to estimate memory requirements. With no real images provided (num_images == 0), the code falls into the if num_images == 0: branch and tries to access self.allowed_mm_limits. This raised:

AttributeError: 'OpenVLAMultiModalProcessor' object has no attribute 'allowed_mm_limits'

This crashed vLLM during model initialization, preventing the model from loading at all.

Why we're confident in the fix: Grepping the entire vLLM model directory, no other model uses self.allowed_mm_limits directly on the processor -- they all access it via self.info.allowed_mm_limits. The property is defined in vllm/multimodal/processing/context.py on the BaseProcessingInfo class.

@mkorhone

mkorhone commented Mar 27, 2026

Copy link
Copy Markdown
Collaborator Author
  1. Why did we add the pixel_values dtype/device cast?
p = next(self.vision_backbone.parameters())
pixel_values = pixel_values.to(device=p.device, dtype=p.dtype)

The HF processor outputs float32 tensors, but the vision backbone expects bfloat16. This is a dtype mismatch between the preprocessing pipeline and the model.

How it manifests: OpenVLA's image preprocessing is done in _preprocess_image_6channel(), which uses PIL/torchvision transforms. These always produce float32 tensors. The pixel_values arrive at _process_image_input() as float32. But the vision backbone (DINOv2 + SigLIP via timm) has its weights loaded in bfloat16 (the model's configured dtype). Passing float32 input into bfloat16 weights causes either:

  • A dtype mismatch error (on strict backends)
  • Silent precision loss or incorrect results (on backends that auto-cast)

Why PalmDr didn't have this: PalmDr's upstream vllm-project PR may have been tested with a framework version that auto-casted inputs, or on a GPU/backend where the dtype mismatch was silently handled. On ROCm/gfx1151, we got incorrect outputs without this cast.

The pattern p = next(self.vision_backbone.parameters()) is defensive: rather than hardcoding bfloat16, it queries the backbone's actual parameter dtype and device. This ensures correctness regardless of what dtype the model is loaded with (bfloat16, float16 for AWQ, etc.).

@mkorhone

mkorhone commented Mar 27, 2026

Copy link
Copy Markdown
Collaborator Author
  1. Why change self.pad_token_id from hardcoded 32000?

PalmDr had:

self.pad_token_id = 32000

We changed to:

self.pad_token_id = self.image_token_index

Both evaluate to 32000 in the current model, since image_token_index defaults to 32000. The change is about semantic correctness and maintainability, not behavior.

Why it matters: pad_token_id and image_token_index are the same value by design in OpenVLA -- the token IS the image placeholder token. Hardcoding 32000 creates a maintenance risk: if someone creates a variant of OpenVLA with a different image_token_index (e.g., for a different base model), the hardcoded 32000 would become wrong while self.image_token_index would automatically pick up the correct value.

The upstream HF config.json has "pad_token_id": 32000 -- confirming the value is correct. But the vLLM OpenVLAConfig class allows image_token_index to be overridden via the constructor, so the semantic link pad_token_id = image_token_index is the correct expression of the invariant.

If challenged: "The value is the same (32000). The change expresses the semantic invariant that pad_token_id and image_token_index are the same token by design. This prevents the two values from diverging if a model variant changes image_token_index."

@mkorhone

Copy link
Copy Markdown
Collaborator Author

This PR is ready to be reviewed again.

@mgehre-amd mgehre-amd left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Nice!

@mgehre-amd mgehre-amd enabled auto-merge March 27, 2026 07:05
@mgehre-amd mgehre-amd merged commit 8dd936a into matthias.awq_gemv Mar 27, 2026
5 of 7 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants