Fix Qwen3 NaN loss: delegate pad_token repair to shared unsloth_zoo.pad_token (#6524)

* Fix Qwen3 NaN: self-heal vision pad_token in load_correct_tokenizer

Text-only Qwen3 (and Qwen2.5) models share Qwen3-VL's vocab, so their
Hub tokenizer configs ship <|vision_pad|> as pad_token. Padding text-only
training with a vision token corrupts attention/loss and produces NaN
losses and gradients on affected stacks.

patch_tokenizer already heals this, but only when a model with config is
passed. The standalone load_correct_tokenizer path (and custom training
loops) still returned <|vision_pad|>. This adds a model-independent guard
in load_correct_tokenizer that replaces a vision pad_token on text-only
tokenizers with the first safe text token (<|endoftext|>, <pad>, [PAD],
<unk>), falling back to eos_token only if it differs from pad_token.

The result now matches upstream Qwen configs (pad_token <|endoftext|>,
id 151643) with no new token added. Vision processors (image_processor
present) and non-vision pad tokens (Llama, Qwen2) are left untouched.

Fixes #3155

* Format pad_token helper for ruff kwarg-spacing hook (pre-commit)

* Harden vision pad_token fix: drop unk candidate, guard get_vocab, skip vision eos

* Tighten code comments (no logic change)

* Delegate pad_token fix to shared unsloth_zoo.pad_token

Generalize the narrow _fix_vision_pad_token by delegating to unsloth_zoo's
shared fix_pad_token (single source of truth, AGPL-3.0), which scans the
reserved-token families instead of only the vision-pad case. A guarded import
keeps this working against an older unsloth_zoo that has not shipped the module
yet: on ImportError it falls back to _fix_vision_pad_token.

allow_add=False is passed so the early load_correct_tokenizer call stays
side-effect free (no model here to resize embeddings); the later model-aware
patch_tokenizer call finishes the job and is idempotent.

Adds tests/python/test_pad_token_fix.py covering both dispatch paths offline.
This commit is contained in:
Daniel Han 2026-06-23 06:29:55 -07:00 committed by GitHub
parent a963ec92e7
commit b91cdc8793
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 185 additions and 0 deletions

View file

@ -0,0 +1,112 @@
"""_fix_pad_token dispatch in unsloth/tokenizer_utils.py.
It must delegate to unsloth_zoo's shared fix_pad_token when present (single
source of truth), and fall back to the narrow vision-token swap against an
older unsloth_zoo. Static + CPU-only: the two helpers are exec'd in isolation
so the test never imports torch / transformers / unsloth.
"""
import ast
import os
import sys
import types
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
TOK_PATH = os.path.join(REPO_ROOT, "unsloth", "tokenizer_utils.py")
WANTED = {
"_VISION_PAD_TOKENS",
"_SAFE_TEXT_PAD_TOKENS",
"_fix_vision_pad_token",
"_fix_pad_token",
}
def _load_pad_helpers():
"""Exec only the pad-token helpers with a stub logger (no heavy imports)."""
tree = ast.parse(open(TOK_PATH).read())
nodes = []
for node in tree.body:
if isinstance(node, ast.Assign):
names = {t.id for t in node.targets if isinstance(t, ast.Name)}
if names & WANTED:
nodes.append(node)
elif isinstance(node, ast.FunctionDef) and node.name in WANTED:
nodes.append(node)
module = ast.Module(body = nodes, type_ignores = [])
ast.fix_missing_locations(module)
ns = {"logger": types.SimpleNamespace(warning = lambda *a, **k: None)}
exec(compile(module, TOK_PATH, "exec"), ns)
return ns
class FakeTok:
def __init__(self, vocab, pad, eos):
self._v = dict(vocab)
self.pad_token = pad
self.eos_token = eos
@property
def pad_token_id(self):
return self._v.get(self.pad_token)
@property
def eos_token_id(self):
return self._v.get(self.eos_token)
def get_vocab(self):
return dict(self._v)
def _block_shared_module(monkeypatch):
# Stub parent so importing it is cheap, and mark the submodule absent.
monkeypatch.setitem(sys.modules, "unsloth_zoo", types.ModuleType("unsloth_zoo"))
monkeypatch.setitem(sys.modules, "unsloth_zoo.pad_token", None)
def test_fix_pad_token_none_is_noop():
ns = _load_pad_helpers()
assert ns["_fix_pad_token"](None) is None
def test_fix_pad_token_falls_back_without_shared_module(monkeypatch):
ns = _load_pad_helpers()
_block_shared_module(monkeypatch)
# Qwen3 text tokenizer shipping a vision pad_token -> narrow swap heals it.
tok = FakeTok(
{"<|endoftext|>": 1, "<|im_end|>": 2, "<|vision_pad|>": 3},
pad = "<|vision_pad|>",
eos = "<|im_end|>",
)
ns["_fix_pad_token"](tok)
assert tok.pad_token == "<|endoftext|>"
assert tok.pad_token != tok.eos_token
def test_fallback_leaves_valid_pad_untouched(monkeypatch):
ns = _load_pad_helpers()
_block_shared_module(monkeypatch)
tok = FakeTok({"<s>": 1, "</s>": 2, "<pad>": 0}, pad = "<pad>", eos = "</s>")
ns["_fix_pad_token"](tok)
assert tok.pad_token == "<pad>"
def test_fix_pad_token_delegates_to_shared_module(monkeypatch):
ns = _load_pad_helpers()
calls = {}
fake = types.ModuleType("unsloth_zoo.pad_token")
def fix_pad_token(tokenizer, allow_add = True):
calls["allow_add"] = allow_add
tokenizer.pad_token = "SHARED"
fake.fix_pad_token = fix_pad_token
monkeypatch.setitem(sys.modules, "unsloth_zoo", types.ModuleType("unsloth_zoo"))
monkeypatch.setitem(sys.modules, "unsloth_zoo.pad_token", fake)
tok = FakeTok({"a": 1}, pad = "x", eos = "y")
ns["_fix_pad_token"](tok)
# Delegated, and crucially with allow_add=False (no model here to resize).
assert tok.pad_token == "SHARED"
assert calls["allow_add"] is False