* 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>
188 lines
6.7 KiB
Python
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()
|