← TinyGPT · docs · devlog · roadmap · speedup
source: docs/learn/qwen3-vl-mrope-deepstack.md · view on GitHub ↗

Qwen3-VL mRoPE + DeepStack — math spec from HF reference

Documented 2026-06-08 from huggingface/transformers/src/transformers/models/qwen3_vl/modeling_qwen3_vl.py. Closes research tasks #277 and #278.

1. Multimodal RoPE (mRoPE)

Shape conventions

For head_dim = 128, freq dimension is head_dim/2 = 64. mrope_section [24, 20, 20] splits the 64 frequencies:

Sum: 24 + 20 + 20 = 64 ✓

Position IDs

Position IDs are 3-dimensional: (T_pos, H_pos, W_pos) per token.

For TEXT tokens: all three dimensions = the scalar text position.

if position_ids.ndim == 2:
    position_ids = position_ids[None, ...].expand(3, ...)  # repeat scalar pos

For IMAGE tokens: each token carries its own (T, H, W) coords. For a single 2D image: T=fixed (e.g., 0), H ∈ [0..h_patches), W ∈ [0..w_patches). For a video: T varies across frames.

Frequency computation

Standard RoPE inv_freq is computed once. Then expanded for the 3 dims:

inv_freq_expanded = inv_freq[None, None, :, None].expand(3, bs, -1, 1)
position_ids_expanded = position_ids[:, :, None, :].float()  # (3, bs, 1, seq)
freqs = inv_freq_expanded @ position_ids_expanded  # (3, bs, head_dim//2, seq)
freqs = freqs.transpose(-1, -2)  # (3, bs, seq, head_dim//2)

So freqs[i] is the frequency tensor for dimension i (T/H/W).

Interleaved mRoPE — the key step

def apply_interleaved_mrope(self, freqs, mrope_section):
    """Reorganizes frequency layout from chunked [TTT...HHH...WWW] to
    interleaved [THWTHWTHW...TT]"""
    freqs_t = freqs[0]  # start from T-frequencies
    for dim, offset in enumerate((1, 2), start=1):  # H=1, W=2
        length = mrope_section[dim] * 3
        idx = slice(offset, length, 3)
        freqs_t[..., idx] = freqs[dim, ..., idx]
    return freqs_t

Visualizing the interleaved layout for mrope_section=[24, 20, 20]:

Indices 0..63 of the freq dimension get assigned:

So H gets indices [1, 4, 7, …, 58] (20 positions, length=60, offset=1, stride=3). W gets indices [2, 5, 8, …, 59] (20 positions, length=60, offset=2, stride=3). T fills the rest: [0, 3, 6, …, 60, 61, 62, 63].

For text-only prompts, freqs[0] == freqs[1] == freqs[2] (since T=H=W=text_pos), so the interleaving is a no-op — collapses to standard 1D RoPE.

Implementation note for MLX-Swift port

The interleaved-write step:

freqs_t = freqs[0]  # copy
freqs_t[..., 1:60:3] = freqs[1, ..., 1:60:3]
freqs_t[..., 2:60:3] = freqs[2, ..., 2:60:3]

In MLX this is just three array writes. The hard part is computing the correct (T, H, W) position triple per token (text vs image distinction).

2. DeepStack visual injection

Mechanism: RESIDUAL ADDITION (not replacement)

def _deepstack_process(self, hidden_states, visual_pos_masks, visual_embeds):
    visual_pos_masks = visual_pos_masks.to(hidden_states.device)
    visual_embeds = visual_embeds.to(hidden_states.device, hidden_states.dtype)
    hidden_states = hidden_states.clone()
    local_this = hidden_states[visual_pos_masks, :] + visual_embeds  # ← residual add
    hidden_states[visual_pos_masks, :] = local_this
    return hidden_states

Only positions where visual_pos_masks == True are augmented. Other positions pass through unchanged.

Where injection happens (the corrected understanding)

IMPORTANT: deepstack_visual_indexes = [5, 11, 17] (in vision_config) refers to vision-tower layer indices where features are TAPPED, NOT the LLM layers where features are injected.

Vision tower forward (in vision encoder, NOT LLM):

for layer_num, blk in enumerate(self.blocks):  # 24 vision blocks
    hidden_states = blk(...)
    if layer_num in self.deepstack_visual_indexes:  # [5, 11, 17]
        deepstack_feature = self.deepstack_merger_list[
            self.deepstack_visual_indexes.index(layer_num)
        ](hidden_states)
        deepstack_feature_lists.append(deepstack_feature)
# Returns: deepstack_feature_lists = [features_from_layer_5,
#                                      features_from_layer_11,
#                                      features_from_layer_17]

LLM decoder forward (in language model):

for layer_idx, layer in enumerate(self.layers):  # 28 LLM blocks
    hidden_states = layer(...)
    if deepstack_visual_embeds is not None \
       and layer_idx in range(len(deepstack_visual_embeds)):  # 0, 1, 2
        hidden_states = self._deepstack_process(
            hidden_states, visual_pos_masks,
            deepstack_visual_embeds[layer_idx]
        )

So:

The scaffold (Qwen3VLDeepstackPlan) needs correcting: the injection LLM-layer indices are [0, 1, 2], NOT [5, 11, 17]. The [5, 11, 17] is where the vision tower SAMPLES features, separate from where they land in the LLM.

Vision-tower merger details

Each tap layer’s features pass through a Qwen3VLVisionPatchMerger (the deepstack_merger_list[i] MLP, hidden_size → out_hidden_size). Per the UI-Venus config: hidden_size=1024 → out_hidden_size=2048.

These are SEPARATE projections from the main merger (the final projection that goes onto the embeddings). UI-Venus has 1 main merger

Position specificity

visual_pos_masks is a (batch, seq_len) bool tensor. True at positions where image tokens live in the prompt; False at text positions. This means deepstack injection only modifies the image-region hidden states, not the text-region ones.

Implications for M4 implementation

SubWhat this changes
M4.1 (loader)Load 3 separate vision_tower.deepstack_merger_list[N] projections + 1 main vision_tower.merger projection. Already documented in factory-vision-m4-impl-plan.md.
M4.2 (mRoPE)Implement apply_interleaved_mrope exactly as above. Three position-id tensors for T/H/W. Apply once per attention layer.
M4.3 (image tokens)Compute visual_pos_masks at embed time — track which positions in the token stream are image-placeholder substitutions.
M4.4 (deepstack)Inject at LLM layers [0, 1, 2], NOT [5, 11, 17]. Residual add via visual_pos_masks. Three sets of deepstack features, one per first-N LLM layers.

Source

transformers/src/transformers/models/qwen3_vl/modeling_qwen3_vl.py — read 2026-06-08