# Copyright 2024 The Qwen team, Alibaba Group and the HuggingFace Inc. team # Copyright 2024 Bytedance Ltd. and/or its affiliates # Based on: # https://github.com/huggingface/transformers/blob/v5.3.0/src/transformers/models/qwen3_5/modeling_qwen3_5.py # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import itertools from typing import Optional import torch from transformers.models.qwen3_5.modeling_qwen3_5 import ( Qwen3_5CausalLMOutputWithPast, Qwen3_5ForConditionalGeneration, Qwen3_5Model, Qwen3_5ModelOutputWithPast, ) def get_rope_index( processor, input_ids: torch.Tensor, image_grid_thw: Optional[torch.Tensor] = None, video_grid_thw: Optional[torch.Tensor] = None, attention_mask: Optional[torch.Tensor] = None, **kwargs, ) -> torch.Tensor: """ Gets the position ids for Qwen3.5, it should be generated before sharding the sequence. The batch dim has been removed and the input_ids should be a 1D tensor representing a single example. Qwen3.5 uses mm_token_type_ids to distinguish modalities (0=text, 1=image, 2=video) and interleaved MRoPE with sections=[11,11,10]. Returns position_ids of shape (3, seq_length) — [temporal, height, width]. """ spatial_merge_size = processor.image_processor.merge_size image_token_id = processor.image_token_id video_token_id = processor.video_token_id # Since we use timestamps to separate videos, # like , # the video_grid_thw should also be split if video_grid_thw is not None: video_grid_thw = torch.repeat_interleave(video_grid_thw, video_grid_thw[:, 0], dim=0) video_grid_thw[:, 0] = 1 if input_ids is not None and (image_grid_thw is not None or video_grid_thw is not None): if attention_mask is None: attention_mask = torch.ones_like(input_ids) position_ids = torch.ones(3, input_ids.shape[0], dtype=input_ids.dtype, device=input_ids.device) attention_mask = attention_mask.to(input_ids.device) input_ids_masked = input_ids[attention_mask == 1] # Build mm_token_type_ids: 0=text, 1=image, 2=video mm_token_type_ids = torch.zeros_like(input_ids_masked) mm_token_type_ids[input_ids_masked == image_token_id] = 1 mm_token_type_ids[input_ids_masked == video_token_id] = 2 # Group tokens by modality type using itertools.groupby image_iter = iter(image_grid_thw) if image_grid_thw is not None else iter([]) video_iter = iter(video_grid_thw) if video_grid_thw is not None else iter([]) input_type_group = [] for key, group in itertools.groupby(enumerate(mm_token_type_ids.tolist()), lambda x: x[1]): group = list(group) start_index = group[0][0] end_index = group[-1][0] + 1 input_type_group.append((key, start_index, end_index)) current_pos = 0 llm_pos_ids_list = [] for modality_type, start_idx, end_idx in input_type_group: if modality_type == 0: # Text tokens: standard 1D position IDs expanded to 3D text_len = end_idx - start_idx llm_pos_ids_list.append( torch.arange(text_len, device=input_ids.device).view(1, -1).expand(3, -1) + current_pos ) current_pos += text_len else: # Vision tokens (image=1, video=2): compute 3D position IDs if modality_type == 1: grid_thw = next(image_iter) else: grid_thw = next(video_iter) # Compute vision position IDs (same logic as Qwen3_5Model.get_vision_position_ids) llm_grid_t = grid_thw[0].item() // 1 # temp_merge_size=1 llm_grid_h = grid_thw[1].item() // spatial_merge_size llm_grid_w = grid_thw[2].item() // spatial_merge_size image_seq_length = llm_grid_h * llm_grid_w * llm_grid_t position_width = torch.arange( current_pos, current_pos + llm_grid_w, device=input_ids.device ).repeat(llm_grid_h * llm_grid_t) position_height = torch.arange( current_pos, current_pos + llm_grid_h, device=input_ids.device ).repeat_interleave(llm_grid_w * llm_grid_t) position_temporal = torch.full( (image_seq_length,), current_pos, device=input_ids.device, dtype=torch.long ) vision_position_ids = torch.stack([position_temporal, position_height, position_width], dim=0) llm_pos_ids_list.append(vision_position_ids) # Advance current_pos by max(h, w) // spatial_merge_size (Qwen3.5 convention) current_pos += max(grid_thw[1].item(), grid_thw[2].item()) // spatial_merge_size llm_positions = torch.cat(llm_pos_ids_list, dim=1).reshape(3, -1) position_ids[..., attention_mask == 1] = llm_positions.to(position_ids.device) else: if attention_mask is not None: position_ids = attention_mask.long().cumsum(-1) - 1 position_ids.masked_fill_(attention_mask == 0, 1) position_ids = position_ids.unsqueeze(0).expand(3, -1).to(attention_mask.device) else: position_ids = torch.arange(input_ids.shape[0], device=input_ids.device).view(1, -1).expand(3, -1) return position_ids def _get_input_embeds( model: "Qwen3_5Model", input_ids: torch.LongTensor, attention_mask: Optional[torch.Tensor] = None, pixel_values: Optional[torch.FloatTensor] = None, pixel_values_videos: Optional[torch.FloatTensor] = None, image_grid_thw: Optional[torch.LongTensor] = None, video_grid_thw: Optional[torch.LongTensor] = None, ): """ Compute input embeddings with visual features injected for Qwen3.5. Qwen3.5 uses model.visual (Qwen3_5VisionModel) which returns BaseModelOutputWithPooling with pooler_output containing split image embeds. No deepstack_visual_indexes in Qwen3.5-2B (empty list). """ inputs_embeds = model.get_input_embeddings()(input_ids) if pixel_values is not None: pixel_values = pixel_values.type(model.visual.dtype) image_outputs = model.visual(pixel_values, grid_thw=image_grid_thw, return_dict=True) image_embeds = image_outputs.pooler_output # pooler_output is already split by image; cat them back split_sizes = (image_grid_thw.prod(-1) // model.visual.spatial_merge_size**2).tolist() image_embeds = torch.split(image_embeds, split_sizes) image_embeds = torch.cat(image_embeds, dim=0) n_image_tokens = (input_ids == model.config.image_token_id).sum().item() n_image_features = image_embeds.shape[0] if n_image_tokens != n_image_features: raise ValueError( f"Image features and image tokens do not match: tokens: {n_image_tokens}, features {n_image_features}" ) mask = input_ids == model.config.image_token_id mask_unsqueezed = mask.unsqueeze(-1) mask_expanded = mask_unsqueezed.expand_as(inputs_embeds) image_mask = mask_expanded.to(inputs_embeds.device) image_embeds = image_embeds.to(inputs_embeds.device, inputs_embeds.dtype) inputs_embeds = inputs_embeds.masked_scatter(image_mask, image_embeds) else: image_mask = None if pixel_values_videos is not None: pixel_values_videos = pixel_values_videos.type(model.visual.dtype) video_outputs = model.visual(pixel_values_videos, grid_thw=video_grid_thw, return_dict=True) video_embeds = video_outputs.pooler_output split_sizes = (video_grid_thw.prod(-1) // model.visual.spatial_merge_size**2).tolist() video_embeds = torch.split(video_embeds, split_sizes) video_embeds = torch.cat(video_embeds, dim=0) n_video_tokens = (input_ids == model.config.video_token_id).sum().item() n_video_features = video_embeds.shape[0] if n_video_tokens != n_video_features: raise ValueError( f"Video features and video tokens do not match: tokens: {n_video_tokens}, features {n_video_features}" ) mask = input_ids == model.config.video_token_id mask_unsqueezed = mask.unsqueeze(-1) mask_expanded = mask_unsqueezed.expand_as(inputs_embeds) video_mask = mask_expanded.to(inputs_embeds.device) video_embeds = video_embeds.to(inputs_embeds.device, inputs_embeds.dtype) inputs_embeds = inputs_embeds.masked_scatter(video_mask, video_embeds) else: video_mask = None # Dummy gradient flow: when neither images nor videos are present, # run a dummy forward through the visual encoder to maintain gradient flow if pixel_values is None and pixel_values_videos is None: config = model.config.vision_config patch_dim = config.in_channels * config.temporal_patch_size * config.patch_size**2 dummy_pixels = torch.zeros((16, patch_dim), dtype=inputs_embeds.dtype, device=inputs_embeds.device) dummy_grid_thw = torch.tensor([[1, 4, 4]], dtype=torch.long, device=inputs_embeds.device) dummy_outputs = model.visual(dummy_pixels, grid_thw=dummy_grid_thw, return_dict=True) dummy_embeds = dummy_outputs.pooler_output if isinstance(dummy_embeds, (list, tuple)): for emb in dummy_embeds: inputs_embeds += 0.0 * emb.mean() else: inputs_embeds += 0.0 * dummy_embeds.mean() # Dummy gradient flow for image-video mixed training: # when only images present, create dummy video input if pixel_values is not None and pixel_values_videos is None: config = model.config.vision_config patch_dim = config.in_channels * config.temporal_patch_size * config.patch_size**2 _video_grid_thw = video_grid_thw if (video_grid_thw is not None) \ else torch.tensor([[2, 4, 4]], dtype=torch.long, device=inputs_embeds.device) _T, _H, _W = _video_grid_thw[0].tolist() _n_tokens = int(_T * _H * _W) _dummy_video_pixels = torch.zeros((_n_tokens, patch_dim), dtype=inputs_embeds.dtype, device=inputs_embeds.device) _video_outputs = model.visual(_dummy_video_pixels, grid_thw=_video_grid_thw, return_dict=True) _video_embeds = _video_outputs.pooler_output if isinstance(_video_embeds, (list, tuple)): for emb in _video_embeds: inputs_embeds = inputs_embeds + 0.0 * emb.mean() else: inputs_embeds = inputs_embeds + 0.0 * _video_embeds.mean() # Dummy gradient flow: when only videos present, create dummy image input if pixel_values is None and pixel_values_videos is not None: config = model.config.vision_config patch_dim = config.in_channels * config.temporal_patch_size * config.patch_size**2 _image_grid_thw = image_grid_thw if (image_grid_thw is not None) \ else torch.tensor([[1, 4, 4]], dtype=torch.long, device=inputs_embeds.device) _T, _H, _W = _image_grid_thw[0].tolist() _n_tokens = int(_T * _H * _W) _dummy_image_pixels = torch.zeros((_n_tokens, patch_dim), dtype=inputs_embeds.dtype, device=inputs_embeds.device) _image_outputs = model.visual(_dummy_image_pixels, grid_thw=_image_grid_thw, return_dict=True) _image_embeds = _image_outputs.pooler_output if isinstance(_image_embeds, (list, tuple)): for emb in _image_embeds: inputs_embeds = inputs_embeds + 0.0 * emb.mean() else: inputs_embeds = inputs_embeds + 0.0 * _image_embeds.mean() if attention_mask is not None: attention_mask = attention_mask.to(inputs_embeds.device) return { "inputs_embeds": inputs_embeds, "attention_mask": attention_mask, } def qwen3_5_base_forward( self: "Qwen3_5Model", input_ids: torch.LongTensor, attention_mask: Optional[torch.Tensor] = None, pixel_values: Optional[torch.FloatTensor] = None, pixel_values_videos: Optional[torch.FloatTensor] = None, image_grid_thw: Optional[torch.LongTensor] = None, video_grid_thw: Optional[torch.LongTensor] = None, **kwargs, ): """ Override Qwen3_5Model.forward for RL training with padding-free sequence packing. This replaces the original forward to: 1. Handle visual embedding injection with dummy gradient flow 2. Support the verl padding-free training pipeline """ position_ids = kwargs.get("position_ids") if isinstance(position_ids, torch.Tensor) and position_ids.ndim not in (2, 3): raise ValueError("position_ids should have shape (batch_size, seq_length) or (3, batch_size, seq_length).") input_kwargs = _get_input_embeds( self, input_ids, attention_mask, pixel_values, pixel_values_videos, image_grid_thw, video_grid_thw, ) kwargs.update(input_kwargs) # avoid lora module to have multiple keyword arguments outputs = self.language_model(input_ids=None, **kwargs) return Qwen3_5ModelOutputWithPast(last_hidden_state=outputs.last_hidden_state) def qwen3_5_model_forward( self: "Qwen3_5ForConditionalGeneration", input_ids: torch.LongTensor, labels: Optional[torch.LongTensor] = None, **kwargs, ) -> "Qwen3_5CausalLMOutputWithPast": """ Override Qwen3_5ForConditionalGeneration.forward for RL training. Calls self.model (Qwen3_5Model) then self.lm_head to get logits. """ outputs = self.model(input_ids=input_ids, **kwargs) hidden_states = outputs[0] logits = self.lm_head(hidden_states) return Qwen3_5CausalLMOutputWithPast(logits=logits)