unsloth/studio/backend/tests/test_training_preflight.py
Daniel Han 48a3a78703
Studio: fail fast on an invalid first training batch (base VLM empty chat template) (#6358)
* Studio: fail fast on an invalid first training batch

Training a base vision-language model (e.g. Qwen/Qwen2-VL-7B or
unsloth/Qwen2-VL-7B) on a conversational image dataset crashed on the first
step with 'Expected ... Long, Int; but got torch.cuda.FloatTensor (embedding)'.
Root cause: the base model's chat template is a flat, media-only template that
renders to an empty string for role-based messages, so UnslothVisionDataCollator
hands the processor empty text, the processor returns empty input_ids, torch
defaults the empty tensor to float32, and the embedding lookup rejects it.

Add a preflight that runs one real batch through the trainer's own tokenization
and collation right before train(), and stops the run with an actionable message
when input_ids is empty or non-integer (pointing to the instruction-tuned variant
for the base-model case). Faithful across text, vision and audio-VLM paths, and
never blocks a run whose first batch is valid.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Trim comments in the training preflight

* Stub unsloth/trl in preflight test so backend CI collection passes

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-18 06:36:33 -07:00

188 lines
6.7 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""_preflight_first_batch rejects an empty/non-integer first batch (the base-model
empty-chat-template crash) before train(). The real methods are bound onto a light
fake self so the production logic runs against controlled batches."""
import importlib
import sys
import types
import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock
import torch
def _stub_if_missing(name, attrs):
"""Register a stub module for a dep the CPU backend CI job does not install.
The pytest job has studio.txt + torch + transformers but not unsloth/trl,
which core.training.trainer imports at module scope. Stub the absent ones
(real installs are left alone) so importing it for the two pure helper
methods never breaks test collection. __spec__ = None keeps the trainer's
own _ensure_real_packages namespace-shadow guard a no-op on the stub.
"""
if name in sys.modules:
return
try:
importlib.import_module(name)
return
except Exception:
pass
mod = types.ModuleType(name)
mod.__spec__ = None
for attr in attrs:
setattr(mod, attr, MagicMock())
sys.modules[name] = mod
parent, _, child = name.rpartition(".")
if parent and parent in sys.modules:
setattr(sys.modules[parent], child, mod)
_stub_if_missing("unsloth", ("FastLanguageModel", "FastVisionModel", "is_bfloat16_supported"))
_stub_if_missing("unsloth.chat_templates", ("get_chat_template",))
_stub_if_missing("trl", ("SFTTrainer", "SFTConfig"))
from core.training.trainer import UnslothTrainer # noqa: E402
_preflight = UnslothTrainer._preflight_first_batch
_renders_empty = UnslothTrainer._chat_template_renders_empty
class _FakeInnerTrainer:
def __init__(
self,
*,
batch = None,
dataloader_error = None,
train_dataset = None,
):
self._batch = batch
self._dataloader_error = dataloader_error
self.train_dataset = train_dataset
def get_train_dataloader(self):
if self._dataloader_error is not None:
raise self._dataloader_error
return [self._batch]
def _fake_self(
*,
inner,
model_name = "org/Some-Model",
tokenizer = None,
):
s = SimpleNamespace(trainer = inner, model_name = model_name, tokenizer = tokenizer)
# Bind real methods so self._chat_template_renders_empty() resolves.
s._preflight_first_batch = _preflight.__get__(s)
s._chat_template_renders_empty = _renders_empty.__get__(s)
return s
class _EmptyTemplateTokenizer:
def apply_chat_template(
self,
messages,
tokenize = False,
add_generation_prompt = False,
):
return ""
class _RealTemplateTokenizer:
def apply_chat_template(
self,
messages,
tokenize = False,
add_generation_prompt = False,
):
return "<|im_start|>user\nhi<|im_end|>"
class TestPreflightFirstBatch(unittest.TestCase):
def test_float_input_ids_with_empty_template_suggests_instruct(self):
ds = [{"messages": [{"role": "user", "content": [{"type": "text", "text": "x"}]}]}]
inner = _FakeInnerTrainer(
batch = {"input_ids": torch.zeros((1, 0), dtype = torch.float32)},
train_dataset = ds,
)
s = _fake_self(
inner = inner, model_name = "Qwen/Qwen2-VL-7B", tokenizer = _EmptyTemplateTokenizer()
)
msg = s._preflight_first_batch()
self.assertIsNotNone(msg)
self.assertIn("chat template", msg)
self.assertIn("Qwen/Qwen2-VL-7B-Instruct", msg)
self.assertIn("base (pretrained) model", msg)
def test_no_instruct_hint_when_model_already_instruct(self):
ds = [{"messages": [{"role": "user", "content": [{"type": "text", "text": "x"}]}]}]
inner = _FakeInnerTrainer(
batch = {"input_ids": torch.zeros((1, 0), dtype = torch.float32)},
train_dataset = ds,
)
s = _fake_self(
inner = inner, model_name = "org/Foo-Instruct", tokenizer = _EmptyTemplateTokenizer()
)
msg = s._preflight_first_batch()
self.assertIsNotNone(msg)
self.assertNotIn("such as", msg) # no Instruct suggestion for an Instruct model
self.assertIn("instruction-tuned variant", msg)
def test_empty_int_input_ids_generic_message(self):
inner = _FakeInnerTrainer(
batch = {"input_ids": torch.zeros((1, 0), dtype = torch.long)},
train_dataset = [{"text": "already tokenized path"}],
)
s = _fake_self(inner = inner, tokenizer = _RealTemplateTokenizer())
msg = s._preflight_first_batch()
self.assertIsNotNone(msg)
self.assertIn("invalid token IDs", msg)
self.assertNotIn("chat template", msg)
def test_valid_batch_returns_none(self):
inner = _FakeInnerTrainer(
batch = {"input_ids": torch.randint(0, 1000, (2, 34), dtype = torch.long)},
)
s = _fake_self(inner = inner)
self.assertIsNone(s._preflight_first_batch())
def test_dataloader_error_is_surfaced(self):
inner = _FakeInnerTrainer(dataloader_error = RuntimeError("boom"))
s = _fake_self(inner = inner, model_name = "org/M")
msg = s._preflight_first_batch()
self.assertIsNotNone(msg)
self.assertIn("failed to build the first training batch", msg)
self.assertIn("org/M", msg)
def test_missing_input_ids_does_not_false_positive(self):
inner = _FakeInnerTrainer(batch = {"pixel_values": torch.zeros((1, 3))})
s = _fake_self(inner = inner)
self.assertIsNone(s._preflight_first_batch())
class TestChatTemplateRendersEmpty(unittest.TestCase):
def _self(self, *, train_dataset, tokenizer):
inner = _FakeInnerTrainer(train_dataset = train_dataset)
return _fake_self(inner = inner, tokenizer = tokenizer)
def test_empty_render_detected(self):
ds = [{"messages": [{"role": "user", "content": [{"type": "text", "text": "x"}]}]}]
s = self._self(train_dataset = ds, tokenizer = _EmptyTemplateTokenizer())
self.assertTrue(s._chat_template_renders_empty())
def test_nonempty_render_not_flagged(self):
ds = [{"messages": [{"role": "user", "content": [{"type": "text", "text": "x"}]}]}]
s = self._self(train_dataset = ds, tokenizer = _RealTemplateTokenizer())
self.assertFalse(s._chat_template_renders_empty())
def test_no_messages_key_not_flagged(self):
s = self._self(train_dataset = [{"text": "raw"}], tokenizer = _EmptyTemplateTokenizer())
self.assertFalse(s._chat_template_renders_empty())
if __name__ == "__main__":
unittest.main()