Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions examples/speculative_decoding/eagle_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ def make_speculative_data_module(
train_len=train_len,
local_image_path=data_args.vlm_img_dir,
return_labels=True,
answer_only_loss=answer_only_loss,
shift_labels=shift_labels,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[CRITICAL Algorithm] VisionLanguageDataCollator does not accept shift_labels, so this call raises TypeError: __init__() got an unexpected keyword argument 'shift_labels' at runtime — crashing the exact VLM online path this PR adds.

VisionLanguageDataCollator.__init__ (modelopt/torch/utils/plugins/transformers_dataset.py:321) is declared as (self, processor, train_len, chat_template, add_generation_prompt, answer_only_loss, local_image_path, return_labels) — it has neither a shift_labels parameter nor **kwargs, and it never forwards shift_labels to super().__init__. The text-only branch above (LanguageDataCollator, line 135) is fine because that class does declare shift_labels, but the VLM subclass does not.

This isn't caught by test_vlm_data_module_passes_dflash_label_mode because that test replaces VisionLanguageDataCollator with a MagicMock, which accepts any kwargs — so the real signature mismatch is masked.

Impact: DFlash for Qwen3-VL requires shift_labels=False, and passing it is the only way to get the unshifted-label behavior DFlash needs — the crash blocks the feature entirely for any real run.

Fix: add shift_labels to VisionLanguageDataCollator.__init__ and forward it to super().__init__(...) (the parent already stores and uses it). For example, in transformers_dataset.py:

def __init__(
    self,
    processor: str,
    train_len: int = 8192,
    chat_template: str | None = None,
    add_generation_prompt: bool = False,
    answer_only_loss: bool = False,
    shift_labels: bool = True,
    local_image_path: str = "",
    return_labels: bool = False,
):
    ...
    super().__init__(
        tokenizer=self.processor.tokenizer,
        train_len=train_len,
        chat_template=chat_template,
        add_generation_prompt=add_generation_prompt,
        answer_only_loss=answer_only_loss,
        shift_labels=shift_labels,
        return_labels=return_labels,
    )

Consider having the test construct the real collator (or assert against its actual signature) so this class of mismatch is caught.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[AI review] The PR description says VisionLanguageDataCollator was extended (propagating answer_only_loss/chat-template/label-alignment settings, VLM_MIN_PIXELS/VLM_MAX_PIXELS limits, ChatML-boundary assistant masks, fixed training_seq_len enforcement), but modelopt/torch/utils/plugins/transformers_dataset.py is not part of this diff — on the current head the class still has its old signature. It looks like that file may not have been committed/pushed.

Note this is broader than the shift_labels TypeError already flagged: even after adding that one parameter, the described mask/pixel/seq-len behaviors would still be missing from the PR.

chat_template=chat_template,
)

else:
Expand Down
29 changes: 22 additions & 7 deletions modelopt/torch/export/plugins/hf_spec_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,23 @@

ALL_SPEC_MODES = ["eagle", "dflash"]


def _get_rope_theta(config, default=None):
"""Get RoPE theta from either legacy or Transformers 5 config fields."""
rope_theta = getattr(config, "rope_theta", None)
if rope_theta is not None:
return rope_theta

# Transformers 5 stores this under rope_parameters (and exposes the same
# data through rope_scaling for backwards compatibility).
for attr in ("rope_parameters", "rope_scaling"):
rope_config = getattr(config, attr, None)
if isinstance(rope_config, dict) and rope_config.get("rope_theta") is not None:
return rope_config["rope_theta"]

return default


LLAMA_EAGLE_SINGLE_LAYER = {
"required": {
"layers.0.self_attn.q_proj",
Expand Down Expand Up @@ -376,13 +393,11 @@ def _export_config(self):
"initializer_range": getattr(base_config, "initializer_range", 0.02),
"attention_bias": getattr(draft_config, "attention_bias", False),
"attention_dropout": getattr(draft_config, "attention_dropout", 0.0),
# Inherit the target's rope_theta: DFlash injects the target's KV into every
# draft layer, so the draft's RoPE base must match the target's. (The draft
# arch config carries no rope_theta of its own.)
"rope_theta": (
getattr(base_config, "rope_theta", None)
if getattr(base_config, "rope_theta", None) is not None
else getattr(draft_config, "rope_theta", 1000000.0)
# Inherit the target's RoPE base: DFlash injects target KV into every draft
# layer, so their RoPE bases must match. Transformers 5 stores rope_theta
# in rope_parameters rather than a top-level config attribute.
"rope_theta": _get_rope_theta(
base_config, _get_rope_theta(draft_config, 1000000.0)
),
# YaRN long-context scaling is injected below (see the rope_scaling block).
"rope_scaling": None,
Expand Down
202 changes: 186 additions & 16 deletions modelopt/torch/speculative/plugins/hf_dflash.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
import logging

import torch
import transformers

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[AI review] nit: import transformers lands between import torch and import torch.nn.functional as F; ruff/isort (I001) will flag this in pre-commit/CI.

import torch.nn.functional as F
from transformers import PreTrainedModel
from transformers.models.qwen3.configuration_qwen3 import Qwen3Config as _Qwen3Config
Expand All @@ -100,6 +101,30 @@
__all__ = ["HFDFlashModel"]


def _expand_qwen3_video_grid_thw(video_grid_thw: torch.Tensor) -> torch.Tensor:
"""Return the per-frame video grid representation used by Qwen3-VL RoPE.

Qwen3-VL's video processor emits one ``[T, H, W]`` row per source video, but
its rendered prompt contains a separate visual-token group for every temporal
frame. Transformers 5.3's ``get_rope_index`` consumes one grid row per
rendered group, while the vision encoder still requires the original one-row-
per-video representation. This helper is therefore used *only* for mRoPE
position construction; callers must keep the original tensor for the model
forward.
"""
if video_grid_thw.ndim != 2 or video_grid_thw.shape[-1] != 3:
raise ValueError(
"Qwen3-VL video_grid_thw must have shape [num_videos, 3], got "
f"{tuple(video_grid_thw.shape)}."
)
if torch.any(video_grid_thw[:, 0] <= 0):
raise ValueError("Qwen3-VL video_grid_thw temporal lengths must be positive.")

expanded_grid_thw = torch.repeat_interleave(video_grid_thw, video_grid_thw[:, 0], dim=0)
expanded_grid_thw[:, 0] = 1
return expanded_grid_thw


def _dpace_position_weights(
confidences: torch.Tensor, alpha: float, valid_mask: torch.Tensor | None = None
) -> torch.Tensor:
Expand Down Expand Up @@ -183,6 +208,98 @@ def _base_llm_config(self):
or self.config
)

def _qwen3_vl_position_ids(
self,
input_ids,
attention_mask,
position_ids,
past_key_values,
inputs_embeds,
model_kwargs,
):
"""Precompute Qwen3-VL mRoPE positions for Transformers 5.3 batches.

The video encoder consumes one grid row per source video, whereas mRoPE
consumes one row per rendered temporal-frame group. Calling the
top-level model with the original video grid makes the two contracts
conflict. Construct the mRoPE positions with a frame-expanded copy,
then pass the original grid to the vision encoder in ``forward``.

Prefer ``get_rope_index`` over ``compute_3d_position_ids``. The latter
writes ``rope_deltas`` into the base model even though DFlash training
never supplies a cache. Keeping this calculation side-effect free is
important when the frozen target is reused for consecutive training
batches or validation.
"""
if (
position_ids is not None
or getattr(self.config, "model_type", None) != "qwen3_vl"
or not transformers.__version__.startswith("5.3.")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

question: is this patch only needed for transformers 5.3, or for all transformers 5.x or >5.3?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[AI review] Two robustness concerns with this gate:

  1. Version pin. Tracing transformers 5.3.0, the per-video vs per-frame grid mismatch looks like a 5.3 bug rather than a stable contract: get_rope_index consumes one grid row per rendered visual group (next(grid_iters[...])), while the processor renders T timestamp-separated frame groups per video and the video processor emits one [T, H, W] row per video. Even the vanilla generation path (_prepare_position_ids_for_generation) passes the raw grid, so upstream video inference appears equally broken on 5.3 — meaning upstream will likely fix this inside get_rope_index (as in 4.x, where the expansion was internal). If that fix lands in a 5.3.x patch release, this external expansion becomes a double expansion and silently corrupts positions; on 5.4+ the helper silently disables itself and multimodal batches fall back to the broken internal path. Suggest linking the upstream issue/commit here, and failing loudly (frame-group count ≠ grid-row count) instead of silently returning None when the gate doesn't match.

  2. model_type != "qwen3_vl" exact match. Family variants like qwen3_vl_moe skip the precomputation but still enter the multimodal top-level forward below — landing exactly in the broken path this helper works around. Consider covering the family or at least warning.

# Cached decoding uses the base model's rope_deltas path. DFlash
# training has no cache and is the only path that needs the
# frame-expanded construction below.
or past_key_values is not None
):
return position_ids

image_grid_thw = model_kwargs.get("image_grid_thw")
video_grid_thw = model_kwargs.get("video_grid_thw")
mm_token_type_ids = model_kwargs.get("mm_token_type_ids")
backbone = getattr(self, "model", None)
get_rope_index = getattr(backbone, "get_rope_index", None)
compute_position_ids = getattr(backbone, "compute_3d_position_ids", None)
if not isinstance(image_grid_thw, torch.Tensor) and not isinstance(
video_grid_thw, torch.Tensor
):
return position_ids
if (
not isinstance(mm_token_type_ids, torch.Tensor)
or input_ids is None
or (not callable(get_rope_index) and not callable(compute_position_ids))
):
raise ValueError(
"Qwen3-VL DFlash training requires input_ids, mm_token_type_ids, and "
"a Qwen3-VL model with get_rope_index or compute_3d_position_ids. "
"Use the Qwen3-VL AutoProcessor without dropping mm_token_type_ids."
)

if mm_token_type_ids.shape != input_ids.shape:
raise ValueError(
"Qwen3-VL mm_token_type_ids must have the same shape as input_ids, got "
f"{tuple(mm_token_type_ids.shape)} and {tuple(input_ids.shape)}."
)

rope_video_grid_thw = video_grid_thw
if isinstance(video_grid_thw, torch.Tensor) and video_grid_thw.numel() > 0:
rope_video_grid_thw = _expand_qwen3_video_grid_thw(video_grid_thw)

rope_kwargs = {
"input_ids": input_ids,
"image_grid_thw": image_grid_thw,
"video_grid_thw": rope_video_grid_thw,
"attention_mask": attention_mask,
"mm_token_type_ids": mm_token_type_ids,
}
if callable(get_rope_index):
position_ids, _ = get_rope_index(**rope_kwargs)
else:
position_ids = compute_position_ids(
**rope_kwargs,
inputs_embeds=inputs_embeds,
past_key_values=past_key_values,
)

expected_shape = (3, *input_ids.shape)
valid_position_ids = isinstance(position_ids, torch.Tensor) and (
tuple(position_ids.shape) == expected_shape
)
if not valid_position_ids:
raise RuntimeError(
"Qwen3-VL produced invalid mRoPE position ids: expected shape "
f"{expected_shape}, got {getattr(position_ids, 'shape', None)}."
)
return position_ids

def _find_base_model_parts(self):
"""Locate base model submodules (backbone, embeddings, lm_head) by probing known paths.

Expand Down Expand Up @@ -592,6 +709,15 @@ def forward(
- Label alignment: position k predicts token at anchor+k
- Optional loss decay weighting
"""
position_ids = self._qwen3_vl_position_ids(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[AI review] This runs before the if not self.training branch, so it also fires on eval prefill (multimodal inputs, no cache). Passing explicit position_ids makes Qwen3VLModel.forward skip compute_3d_position_ids, so self.rope_deltas is never established; a subsequent cached decode step (position_ids=None, past_key_values not None → this helper returns None) then falls into the rope_deltas-based branch with a stale/None delta and computes wrong positions.

Since the docstring says this construction is only needed for DFlash training, consider gating on self.training.

input_ids,
attention_mask,
position_ids,
past_key_values,
inputs_embeds,
kwargs,
)

if not self.training:
if self.dflash_offline:
raise RuntimeError(
Expand Down Expand Up @@ -638,12 +764,49 @@ def forward(
)
target_hidden = base_outputs.target_hidden
else:
# TODO: For co-training the base model, remove no_grad and eval() switch.
# Multimodal models need the top-level conditional-generation forward so their
# image/video features are inserted before the language model runs. Keep the
# long-standing narrow call for text-only models.
multimodal_keys = {
"pixel_values",
"pixel_values_videos",
"image_grid_thw",
"video_grid_thw",
"image_sizes",
"images",
"videos",
}
use_top_level_forward = any(kwargs.get(key) is not None for key in multimodal_keys)
with torch.no_grad():
raw_outputs = super().forward(
input_ids=input_ids,
attention_mask=attention_mask,
output_hidden_states=True,
if use_top_level_forward:
base_forward_kwargs = dict(kwargs)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[AI review] base_forward_kwargs forwards everything except assistant_masks/loss_mask. Anything else that reaches forward's **kwargs — e.g. Trainer-injected num_items_in_batch (Trainer detects that this forward accepts **kwargs and adds loss kwargs), or stray dataset columns — gets passed to the HF multimodal forward and can raise TypeError or be silently swallowed depending on version. The text-only branch below is immune because it forwards a fixed argument set.

Consider an allowlist (the multimodal keys + known model kwargs) or filtering against the base forward's signature.

# Training-only data keys are not accepted by Hugging Face model forwards.
base_forward_kwargs.pop("assistant_masks", None)
base_forward_kwargs.pop("loss_mask", None)
raw_outputs = super().forward(
input_ids=input_ids,
attention_mask=attention_mask,
position_ids=position_ids,
past_key_values=past_key_values,
inputs_embeds=inputs_embeds,
use_cache=False,
output_attentions=output_attentions,
output_hidden_states=True,
cache_position=cache_position,
return_dict=True,
**base_forward_kwargs,
)
else:
raw_outputs = super().forward(
input_ids=input_ids,
attention_mask=attention_mask,
output_hidden_states=True,
)

if not getattr(raw_outputs, "hidden_states", None):
raise RuntimeError(
"The base model did not return hidden states required for DFlash training. "
"Ensure its top-level multimodal forward supports output_hidden_states=True."
)
offset = 1
selected = [raw_outputs.hidden_states[lid + offset] for lid in self.target_layer_ids]
Expand All @@ -652,16 +815,15 @@ def forward(
target_hidden=target_hidden, logits=raw_outputs.logits
)

# 2. Build loss mask.
# When labels are provided (answer_only_loss), they already encode both
# assistant masking and padding (-100 for both). When labels are not
# provided, fall back to attention_mask for padding only.
# 2. Build loss mask. Labels carry optional answer-only masking, but do
# not in general mark padded tokens with -100 (the VLM collator creates
# them from padded input_ids). Always intersect with attention_mask so
# anchor sampling and loss never include the padded tail.
loss_mask = torch.ones(bsz, seq_len, device=device)
if labels is not None:
loss_mask = (labels != LabelSmoother.ignore_index).float()
elif attention_mask is not None:
loss_mask = attention_mask.float()
else:
loss_mask = torch.ones(bsz, seq_len, device=device)
loss_mask = loss_mask * (labels != LabelSmoother.ignore_index).float()
if attention_mask is not None:
loss_mask = loss_mask * attention_mask.float()

# In offline training, assistant mask is dumped and passed as kwarg.
if kwargs.get("loss_mask") is not None:
Expand All @@ -674,8 +836,16 @@ def forward(
n_blocks = anchor_positions.shape[1]

if n_blocks == 0 or not block_keep_mask.any():
# Zero loss that still flows through dflash_module for DDP gradient sync
dummy = self.dflash_module.fc.weight.sum() * 0.0
# Keep all trainable draft parameters in the graph so DDP can reduce a rank
# that receives an all-masked answer-only batch.
dummy = sum(
(
parameter.reshape(-1)[0] * 0.0
for parameter in self.dflash_module.parameters()
if parameter.requires_grad
),
torch.zeros((), device=device),
)
return ModelOutput(loss=dummy, logits=base_outputs.logits, train_acc=[[0.0]])

# 4. Build draft inputs
Expand Down
13 changes: 13 additions & 0 deletions tests/unit/torch/export/test_hf_spec_rope_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,16 @@ def test_dflash_rope_theta_inherits_base():
"""rope_theta is inherited from the target/base config (draft drafts for the base)."""
config = _make_dflash_exporter(base_rope_theta=5000000.0)._export_config()
assert config["rope_theta"] == 5000000.0


def test_dflash_rope_theta_inherits_base_rope_parameters():
"""Transformers 5 stores the target RoPE base in rope_parameters."""
exporter = _make_dflash_exporter(base_rope_theta=None)
exporter.model.config.rope_parameters = {
"rope_type": "default",
"rope_theta": 5000000.0,
}

config = exporter._export_config()

assert config["rope_theta"] == 5000000.0
Loading