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
17 changes: 15 additions & 2 deletions tests/chat_template/test_chat_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,20 @@ def test_qwen35vl_template(self):
add_generation_prompt=False)
self.assertEqual(decode_str, hf_text)
else:
# Transformers 5.14 preserves one outer vision boundary around the
# per-frame placeholders expanded from a video token.
if j==15:
self.assertTrue('Video 1: <|vision_start|><|video_pad|><|vision_end|><|vision_start|><|video_pad|><|vision_end|><|vision_start|><|video_pad|><|vision_end|><0.0-10.0 seconds>Describe the video in detail. [NO_REASONING]<|im_end|>' in decode_str)
expected = (
'Video 1: <|vision_start|><|vision_start|><|video_pad|><|vision_end|>'
'<|vision_start|><|video_pad|><|vision_end|><|vision_start|><|video_pad|>'
'<|vision_end|><|vision_end|><0.0-10.0 seconds>Describe the video in detail. '
'[NO_REASONING]<|im_end|>'
)
else:
self.assertTrue('Video 1: <0.0 seconds><|vision_start|><|video_pad|><|vision_end|><1.0 seconds><|vision_start|><|video_pad|><|vision_end|><2.0 seconds><|vision_start|><|video_pad|><|vision_end|><0.0-10.0 seconds>Describe the video in detail. [NO_REASONING]<|im_end|>' in decode_str)
expected = (
'Video 1: <|vision_start|><0.0 seconds><|vision_start|><|video_pad|>'
'<|vision_end|><1.0 seconds><|vision_start|><|video_pad|><|vision_end|>'
'<2.0 seconds><|vision_start|><|video_pad|><|vision_end|><|vision_end|>'
'<0.0-10.0 seconds>Describe the video in detail. [NO_REASONING]<|im_end|>'
)
self.assertIn(expected, decode_str)
52 changes: 41 additions & 11 deletions tests/model/test_qwen3_5.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,26 @@
VIDEO_ROOT = os.environ["VIDEO_ROOT"]

@unittest.skipIf(
Version(transformers_version) < Version("5.2.0"),
f"transformers >= 5.2.0 is required, but got {transformers_version}"
Version(transformers_version) < Version("5.14.0"),
f"transformers >= 5.14.0 is required, but got {transformers_version}"
)
class TestQwen3_5_VL(DeterministicDDPTestCase):
def _patch_xtuner_fast_pos_embed_interpolate(self) -> None:
from transformers.vision_utils import get_vision_bilinear_indices_and_weights

from xtuner.v1.model.compose.qwen3_vl.modeling_vision import Qwen3VLVisionModel
from transformers.models.qwen3_5_moe import Qwen3_5MoeVisionModel
Qwen3VLVisionModel.fast_pos_embed_interpolate = Qwen3_5MoeVisionModel.fast_pos_embed_interpolate

# Follow the current Transformers vision path: accumulate interpolation in fp32,
# then return to the model dtype before XTuner's first LayerNorm.
def _interp(self, grid_thw):
indices, weights = get_vision_bilinear_indices_and_weights(
grid_thw,
num_grid_per_side=self.num_grid_per_side,
spatial_merge_size=self.config.spatial_merge_size,
)
return (self.pos_embed(indices) * weights[:, :, None]).sum(0).to(self.pos_embed.weight.dtype)

Qwen3VLVisionModel.fast_pos_embed_interpolate = _interp

def _forward(self, model, type, device, sp_size):
QWEN3_VL_MOE_PATH = os.environ["QWEN3_5_MOE_PATH"]
Expand Down Expand Up @@ -214,8 +226,23 @@ def test_qwen3_5_vl_run(self, device, sp_size, tol):
loss_xtuner_image = self._forward(qwen3vl_model, type='image',device=device, sp_size=sp_size)
loss_xtuner_video = self._forward(qwen3vl_model, type='video',device=device, sp_size=sp_size)

self.assertTrue(torch.allclose(loss_xtuner_text, loss_hf_text.to(loss_xtuner_text.dtype), atol=tol, rtol=tol))
self.assertTrue(torch.allclose(loss_xtuner_image, loss_hf_image.to(loss_xtuner_image.dtype), atol=tol, rtol=tol))
self.assertTrue(
torch.allclose(loss_xtuner_text, loss_hf_text.to(loss_xtuner_text.dtype), atol=tol, rtol=tol),
f"Text loss mismatch: HF={loss_hf_text.item()}, XTuner={loss_xtuner_text.item()}, tol={tol}",
)
# Transformers 5.14 changed Qwen3.5's fused GatedDeltaNet/MoE path. The vision output and
# language-model input remain bitwise equal, but the fused language kernels accumulate a
# small numerical drift over 40 layers on the longer image sequence.
image_tol = 3e-2
self.assertTrue(
torch.allclose(
loss_xtuner_image,
loss_hf_image.to(loss_xtuner_image.dtype),
atol=image_tol,
rtol=image_tol,
),
f"Image loss mismatch: HF={loss_hf_image.item()}, XTuner={loss_xtuner_image.item()}, tol={image_tol}",
)
# self.assertTrue(torch.allclose(loss_xtuner_video, loss_hf_video.to(loss_xtuner_video.dtype), atol=tol, rtol=tol))

del qwen3vl_model
Expand Down Expand Up @@ -255,12 +282,13 @@ def test_qwen3_5_vl_run_mtp(self, device, sp_size, tol):
self.create_pg(device)
self._patch_xtuner_fast_pos_embed_interpolate()

# pt29 + transformers 5.2.0 with XTUNER_DETERMINISTIC=true, which pins Triton autotune.
# The 11.5k-token video has a stable SP-specific LM-loss baseline on this path.
# pt29 + transformers 5.14.1 with XTUNER_DETERMINISTIC=true, which pins Triton autotune.
# Transformers now preserves the outer vision boundary around each video, so the resulting
# 11.5k-token supervision sequence has a new stable, SP-specific LM-loss baseline.
loss_reference = {
"text": 1.4981,
"image": 3.6109,
"video": {1: 9.3212, 4: 8.6532}[sp_size],
"image": 3.6325,
"video": {1: 7.1987, 4: 6.8464}[sp_size],
}

QWEN3_VL_MOE_PATH = os.environ["QWEN3_5_MOE_PATH"]
Expand Down Expand Up @@ -289,6 +317,7 @@ def test_qwen3_5_vl_run_mtp(self, device, sp_size, tol):
losses["text"] = loss_xtuner_text
losses["image"] = loss_xtuner_image
losses["video"] = loss_xtuner_video
actual_losses = {key: loss.item() for key, loss in losses.items()}

for key, loss in losses.items():
self.assertTrue(
Expand All @@ -301,7 +330,8 @@ def test_qwen3_5_vl_run_mtp(self, device, sp_size, tol):
atol=tol,
rtol=tol
),
f"Expected text loss around {key}, but got {loss.item()}"
f"Expected {key} loss around {loss_reference[key]}, but got {actual_losses[key]}; "
f"all losses: {actual_losses}"
)


Expand Down
20 changes: 12 additions & 8 deletions tests/model/test_qwen3_5_dense.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@


@unittest.skipIf(
Version(transformers_version) < Version("5.9.0"),
f"transformers >= 5.9.0 is required, but got {transformers_version}",
Version(transformers_version) < Version("5.14.0"),
f"transformers >= 5.14.0 is required, but got {transformers_version}",
)
class TestQwen3_5_VLDense(DeterministicDDPTestCase):
@parametrize.parametrize("device,layer_idx", [("cuda", 3), ("cuda", 0)])
Expand Down Expand Up @@ -99,7 +99,7 @@ def test_decoder_layer_bitwise_parity(self, device, layer_idx):
loss_hf.backward()

x_xt = base.clone().requires_grad_(True)
o_xt = xt_layer(x_xt, (cos, sin), seq_ctx)
o_xt = xt_layer(x_xt, position_embeddings=(cos, sin), seq_ctx=seq_ctx)
loss_xt = F.cross_entropy(F.linear(model.norm(o_xt), model.lm_head.weight).reshape(-1, cfg.vocab_size), labels)
loss_xt.backward()

Expand Down Expand Up @@ -441,15 +441,19 @@ def test_save_hf_round_trip(self, device):
dist.barrier()

def _patch_fast_pos_embed_interpolate(self) -> None:
# HF's fast_pos_embed_interpolate returns fp32; the reused XTuner vision forward adds
# pos_embeds without a cast, so cast the result back to the pos_embed dtype here to
# avoid an fp32/bf16 LayerNorm mismatch.
from transformers.models.qwen3_5.modeling_qwen3_5 import Qwen3_5VisionModel
from transformers.vision_utils import get_vision_bilinear_indices_and_weights

from xtuner.v1.model.compose.qwen3_vl.modeling_vision import Qwen3VLVisionModel

# Follow the current Transformers vision path: accumulate interpolation in fp32,
# then return to the model dtype before XTuner's first LayerNorm.
def _interp(self, grid_thw):
return Qwen3_5VisionModel.fast_pos_embed_interpolate(self, grid_thw).to(self.pos_embed.weight.dtype)
indices, weights = get_vision_bilinear_indices_and_weights(
grid_thw,
num_grid_per_side=self.num_grid_per_side,
spatial_merge_size=self.config.spatial_merge_size,
)
return (self.pos_embed(indices) * weights[:, :, None]).sum(0).to(self.pos_embed.weight.dtype)

Qwen3VLVisionModel.fast_pos_embed_interpolate = _interp

Expand Down
5 changes: 3 additions & 2 deletions tests/model/test_qwen3_tile_embedding.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
import torch
from torch.testing._internal.common_distributed import DistributedTestBase
import torch.distributed as dist
from transformers import AutoTokenizer
# Import concrete symbols only. Third-party parametrize scans global module
# dictionaries, which can mutate while Transformers resolves lazy imports.
from transformers import AutoTokenizer
import tempfile
from pathlib import Path
from safetensors import safe_open
from unittest import skipIf
import transformers
from packaging import version

from xtuner.v1.data_proto import SequenceContext
Expand Down
4 changes: 4 additions & 0 deletions xtuner/v1/data_proto/messages/qwen35_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ def render_content(content, do_vision_count, image_count, video_count, add_visio
video_content = item.get("video", item.get("video_url", {}))
assert isinstance(video_content, dict), f"video_content must be a dict, but got {type(video_content)}"
timestamps = video_content.get("timestamps", [])
# Match Transformers' processor replacement: per-frame placeholders stay
# nested inside the video boundary emitted by the chat template.
result += "<|vision_start|>"
if len(timestamps) > 0:
video_placeholder = ""
for timestamp in timestamps:
Expand All @@ -76,6 +79,7 @@ def render_content(content, do_vision_count, image_count, video_count, add_visio
num_frames = video_content["num_frames"]
for _ in range(len(num_frames)):
result += "<|vision_start|><|video_pad|><|vision_end|>"
result += "<|vision_end|>"
conversation_timestamp = video_content.get("conversation_timestamps", [])
if len(conversation_timestamp) > 0:
start_time = conversation_timestamp[0]
Expand Down
5 changes: 5 additions & 0 deletions xtuner/v1/datasets/mllm_tokenize_fn/qwen3_vl_tokenize_fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,11 @@ def replace_video_token(
video_placeholder += f"<{curr_time:.1f} seconds>{image_tokens}"
else:
video_placeholder += f"{image_tokens}"
# Current Transformers processors replace only the inner video token and
# preserve the chat template's outer vision boundaries around the video.
video_placeholder = (
f"{chat_template.image_start_token}{video_placeholder}{chat_template.image_end_token}"
)
text = text.replace(IMAGE_TOKEN_ALIAS, video_placeholder, 1)
current_image_idx += len(num_image_token_list[i])
c.text = text
Expand Down
Loading