Skip to content

Commit b07d0cd

Browse files
committed
Add scoped vision export support for LLaVA-OneVision-1.5
Prepare OV1.5 frame geometry before tracing and register its vision attention class with the existing exporter adapter. Restrict the new input preparation branch to the OV1.5 vision model type. Test static and dynamic export with learned class tokens, isolated frames and aspect-ratio changes at fixed patch counts. Document the uniform-patch-count and fixed-frame-count export constraints.
1 parent 78503c3 commit b07d0cd

6 files changed

Lines changed: 75 additions & 14 deletions

File tree

‎docs/source/en/model_doc/llava_onevision1_5.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ output = model.generate(**inputs, max_new_tokens=64)
4040
processor.batch_decode(output[:, inputs.input_ids.shape[1]:], skip_special_tokens=True)
4141
```
4242

43+
## Export
44+
45+
The vision encoder can be exported with `DynamoExporter`, which prepares grid metadata before tracing. The shared vision attention export adapter requires equal patch counts per frame. The number of frames and their patch counts are specialized to the export inputs; export a new graph when these change.
46+
4347
## LlavaOnevision15Config
4448

4549
[[autodoc]] LlavaOnevision15Config

‎src/transformers/exporters/exporter_dynamo.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,7 @@ def _to_batched(t):
388388
# Combined `qkv` + `(cos, sin)` rotary + `.proj`
389389
"transformers.models.qwen2_vl.modeling_qwen2_vl.VisionAttention.forward",
390390
"transformers.models.qwen2_5_vl.modeling_qwen2_5_vl.Qwen2_5_VLVisionAttention.forward",
391+
"transformers.models.llava_onevision1_5.modeling_llava_onevision1_5.LlavaOnevision15VisionAttention.forward",
391392
"transformers.models.qwen3_vl.modeling_qwen3_vl.Qwen3VLVisionAttention.forward",
392393
"transformers.models.qwen3_vl_moe.modeling_qwen3_vl_moe.Qwen3VLMoeVisionAttention.forward",
393394
"transformers.models.qwen3_5.modeling_qwen3_5.Qwen3_5VisionAttention.forward",

‎src/transformers/exporters/utils.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,9 @@ def _prepare_grid_thw_vision_inputs(model: torch.nn.Module, inputs: dict[str, An
519519
include_temporal = _find_submodule_attr(model, "axis_dim") is not None
520520
inputs["position_ids"] = get_vision_position_ids(grid_thw, spatial_merge_size, include_temporal=include_temporal)
521521

522+
if model.config.model_type == "llava_onevision1_5_vision":
523+
inputs["frame_lengths"] = module.get_vision_frame_lengths(inputs["cu_seqlens"])
524+
522525
window_size = _find_submodule_attr(model, "window_size")
523526
patch_size = _find_submodule_attr(model, "patch_size")
524527
if window_size is not None and patch_size is not None:

‎src/transformers/models/llava_onevision1_5/modeling_llava_onevision1_5.py‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
merge_with_config_defaults,
4141
)
4242
from ...utils.output_capturing import capture_outputs
43-
from ...vision_utils import get_vision_position_ids
43+
from ...vision_utils import get_vision_attention_seqlens, get_vision_position_ids
4444
from ..auto import AutoModel
4545
from .configuration_llava_onevision1_5 import LlavaOnevision15Config, LlavaOnevision15VisionConfig
4646

@@ -334,6 +334,13 @@ def _init_weights(self, module):
334334
init.normal_(module.class_pos_emb, mean=0.0, std=std)
335335

336336

337+
def get_vision_frame_lengths(cu_seqlens: torch.Tensor, kwargs: dict | None = None) -> list[int]:
338+
"""Get patch counts per frame, or consume geometry prepared before graph capture."""
339+
if kwargs is not None and (frame_lengths := kwargs.pop("frame_lengths", None)) is not None:
340+
return frame_lengths
341+
return (cu_seqlens[1:] - cu_seqlens[:-1]).tolist()
342+
343+
337344
@auto_docstring
338345
class LlavaOnevision15VisionModel(LlavaOnevision15PreTrainedModel):
339346
config: LlavaOnevision15VisionConfig
@@ -368,22 +375,23 @@ def forward(self, hidden_states: torch.Tensor, grid_thw: torch.Tensor, **kwargs)
368375
Temporal, height and width dimensions of the patch grid for each image or video.
369376
"""
370377
hidden_states = self.patch_embed(hidden_states)
371-
position_ids = get_vision_position_ids(grid_thw, self.spatial_merge_size)
378+
position_ids = get_vision_position_ids(grid_thw, self.spatial_merge_size, kwargs=kwargs)
372379
cos, sin = self.rotary_pos_emb(hidden_states, position_ids)
373-
lengths = torch.repeat_interleave(grid_thw[:, 1] * grid_thw[:, 2], grid_thw[:, 0])
374-
frame_lengths = lengths.tolist()
380+
cu_seqlens, max_seqlen = get_vision_attention_seqlens(grid_thw, self.config, kwargs=kwargs)
381+
frame_lengths = get_vision_frame_lengths(cu_seqlens, kwargs=kwargs)
382+
cu_seqlens = cu_seqlens + torch.arange(cu_seqlens.shape[0], device=cu_seqlens.device, dtype=cu_seqlens.dtype)
383+
if max_seqlen is not None:
384+
max_seqlen += 1
375385
cls_hidden = self.class_embedding.to(hidden_states.dtype).unsqueeze(0)
376386
cls_freqs = torch.cat((self.class_pos_emb, self.class_pos_emb), dim=-1).to(cos.dtype)
377387
cls_cos, cls_sin = cls_freqs.cos().to(cos.dtype), cls_freqs.sin().to(sin.dtype)
378388
hidden_states, cos, sin = [
379389
torch.cat([torch.cat((cls, frame), dim=0) for frame in torch.split(value, frame_lengths)], dim=0)
380390
for value, cls in ((hidden_states, cls_hidden), (cos, cls_cos), (sin, cls_sin))
381391
]
382-
cu_seqlens = nn.functional.pad((lengths + 1).cumsum(0, dtype=torch.int32), (1, 0))
383-
384392
hidden_states = self.pre_layernorm(hidden_states)
385393
for block in self.blocks:
386-
hidden_states = block(hidden_states, cu_seqlens, cos, sin, **kwargs)
394+
hidden_states = block(hidden_states, cu_seqlens, cos, sin, max_seqlen=max_seqlen, **kwargs)
387395

388396
hidden_states = torch.cat(
389397
[frame[1:] for frame in torch.split(hidden_states, [length + 1 for length in frame_lengths])], dim=0

‎src/transformers/models/llava_onevision1_5/modular_llava_onevision1_5.py‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from ...utils import auto_docstring
2828
from ...utils.generic import merge_with_config_defaults
2929
from ...utils.output_capturing import capture_outputs
30-
from ...vision_utils import get_vision_position_ids
30+
from ...vision_utils import get_vision_attention_seqlens, get_vision_position_ids
3131
from ..auto import AutoConfig, AutoModel
3232
from ..qwen2_5_vl.configuration_qwen2_5_vl import Qwen2_5_VLConfig, Qwen2_5_VLVisionConfig
3333
from ..qwen2_5_vl.modeling_qwen2_5_vl import (
@@ -114,6 +114,13 @@ def __post_init__(self, **kwargs):
114114
raise ValueError("vision_config.out_hidden_size must match text_config.hidden_size")
115115

116116

117+
def get_vision_frame_lengths(cu_seqlens: torch.Tensor, kwargs: dict | None = None) -> list[int]:
118+
"""Get patch counts per frame, or consume geometry prepared before graph capture."""
119+
if kwargs is not None and (frame_lengths := kwargs.pop("frame_lengths", None)) is not None:
120+
return frame_lengths
121+
return (cu_seqlens[1:] - cu_seqlens[:-1]).tolist()
122+
123+
117124
class LlavaOnevision15VisionPatchEmbed(nn.Module):
118125
def __init__(self, config: LlavaOnevision15VisionConfig):
119126
super().__init__()
@@ -239,22 +246,23 @@ def forward(self, hidden_states: torch.Tensor, grid_thw: torch.Tensor, **kwargs)
239246
Temporal, height and width dimensions of the patch grid for each image or video.
240247
"""
241248
hidden_states = self.patch_embed(hidden_states)
242-
position_ids = get_vision_position_ids(grid_thw, self.spatial_merge_size)
249+
position_ids = get_vision_position_ids(grid_thw, self.spatial_merge_size, kwargs=kwargs)
243250
cos, sin = self.rotary_pos_emb(hidden_states, position_ids)
244-
lengths = torch.repeat_interleave(grid_thw[:, 1] * grid_thw[:, 2], grid_thw[:, 0])
245-
frame_lengths = lengths.tolist()
251+
cu_seqlens, max_seqlen = get_vision_attention_seqlens(grid_thw, self.config, kwargs=kwargs)
252+
frame_lengths = get_vision_frame_lengths(cu_seqlens, kwargs=kwargs)
253+
cu_seqlens = cu_seqlens + torch.arange(cu_seqlens.shape[0], device=cu_seqlens.device, dtype=cu_seqlens.dtype)
254+
if max_seqlen is not None:
255+
max_seqlen += 1
246256
cls_hidden = self.class_embedding.to(hidden_states.dtype).unsqueeze(0)
247257
cls_freqs = torch.cat((self.class_pos_emb, self.class_pos_emb), dim=-1).to(cos.dtype)
248258
cls_cos, cls_sin = cls_freqs.cos().to(cos.dtype), cls_freqs.sin().to(sin.dtype)
249259
hidden_states, cos, sin = [
250260
torch.cat([torch.cat((cls, frame), dim=0) for frame in torch.split(value, frame_lengths)], dim=0)
251261
for value, cls in ((hidden_states, cls_hidden), (cos, cls_cos), (sin, cls_sin))
252262
]
253-
cu_seqlens = nn.functional.pad((lengths + 1).cumsum(0, dtype=torch.int32), (1, 0))
254-
255263
hidden_states = self.pre_layernorm(hidden_states)
256264
for block in self.blocks:
257-
hidden_states = block(hidden_states, cu_seqlens, cos, sin, **kwargs)
265+
hidden_states = block(hidden_states, cu_seqlens, cos, sin, max_seqlen=max_seqlen, **kwargs)
258266

259267
hidden_states = torch.cat(
260268
[frame[1:] for frame in torch.split(hidden_states, [length + 1 for length in frame_lengths])], dim=0

‎tests/models/llava_onevision1_5/test_modeling_llava_onevision1_5.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
require_flash_attn,
3737
require_torch,
3838
require_torch_gpu,
39+
require_torch_greater_or_equal,
3940
slow,
4041
torch_device,
4142
)
@@ -338,6 +339,42 @@ def test_mismatching_num_image_tokens(self):
338339
class LlavaOnevision15VisionModelTest(unittest.TestCase):
339340
all_model_classes = (LlavaOnevision15VisionModel,) if is_torch_available() else ()
340341

342+
@slow
343+
@require_torch_greater_or_equal("2.11")
344+
def test_export_preserves_class_tokens_and_frame_isolation(self):
345+
from transformers.exporters import DynamoConfig, DynamoExporter
346+
from transformers.exporters.utils import prepare_for_export
347+
348+
config = LlavaOnevision15VisionConfig(
349+
hidden_size=32, out_hidden_size=32, intermediate_size=48, depth=2, num_heads=4
350+
)
351+
model = LlavaOnevision15VisionModel(config).to(torch_device).eval()
352+
# One image and two video frames, with 16 patches per frame.
353+
inputs = {
354+
"hidden_states": floats_tensor([48, 3 * 14 * 14]),
355+
"grid_thw": torch.tensor([[1, 4, 4], [2, 4, 4]], device=torch_device),
356+
}
357+
for dynamic in (False, True):
358+
with self.subTest(dynamic=dynamic):
359+
sample = copy.deepcopy(inputs)
360+
exported = DynamoExporter().export(model, sample, config=DynamoConfig(dynamic=dynamic))
361+
# Replay with new pixels and aspect ratio, retaining 16 patches per frame.
362+
sample["hidden_states"] = floats_tensor([48, 3 * 14 * 14])
363+
sample["grid_thw"] = torch.tensor([[1, 2, 8], [2, 2, 8]], device=torch_device)
364+
model, sample, _ = prepare_for_export(model, sample)
365+
with torch.no_grad():
366+
actual = exported.module()(**sample)
367+
expected = model(sample["hidden_states"], sample["grid_thw"])
368+
separate = torch.cat(
369+
[
370+
model(frame, sample["grid_thw"][:1]).pooler_output
371+
for frame in sample["hidden_states"].split(16)
372+
]
373+
)
374+
torch.testing.assert_close(actual.last_hidden_state, expected.last_hidden_state)
375+
torch.testing.assert_close(actual.pooler_output, expected.pooler_output)
376+
torch.testing.assert_close(actual.pooler_output, separate)
377+
341378
def test_standalone_save_load_and_frame_isolation(self):
342379
config = LlavaOnevision15VisionConfig(
343380
hidden_size=32, out_hidden_size=32, intermediate_size=48, depth=2, num_heads=4

0 commit comments

Comments
 (0)