unsloth/tests/test_transformers5_bare_annotation_live.py
Daniel Han 447d241f0f
Keep up with transformers 5.x, TRL 0.22.x and peft (#7866)
* Keep up with transformers 5.x, TRL 0.22.x and peft

Five compatibility failures, each of which stopped a load or a run outright.

transformers 5.x turns PretrainedConfig subclasses into dataclasses. vLLM's
transformers_utils/configs/deepseek_vl2.py declares

    vision_config: VisionEncoderConfig

with no default, and a dataclass will not accept a non-default field after an
inherited default one:

    TypeError: non-default argument 'vision_config' follows default argument

That fires while importing vllm.transformers_utils.configs, which takes down
import vllm and, because unsloth imports vLLM, import unsloth as well. Seen in
the wild as unsloth: "ABSENT: TypeError".

PretrainedConfig.__init_subclass__ is patched rather than vLLM's source, so it
covers every affected class in any vLLM version instead of one file, and the
original stays reachable through __wrapped__: an irreversible monkey patch
cannot be tested against the failure it fixes, and cannot be undone by anyone
debugging a config problem downstream of it.

The window is narrower than it first looked. transformers 5.6.0 started passing
kw_only=True to the dataclass, which removes the ordering rule entirely, so the
fix now stands down on 5.6.0+ rather than monkey patching every config subclass
for no benefit. Read from the source rather than pinned to a version, since the
change could be backported. Verified live on three transformers: 4.57.6 (no-op),
5.5.0 (failure reproduces, fix works), 5.14.1 (upstream handles it, fix stands
down).

offload_embedding = True raised NotImplementedError on a tied-embedding model.
It is a VRAM optimisation, not a correctness switch, and on a tied model
embed_tokens IS lm_head, so offloading strands the output projection on CPU and
frees nothing. Turned off with a message instead, the way fast_inference already
degrades. Resolved before _attach_bnb_multidevice_hooks, which returns early
while the flag is still True; resolving it later would silently skip hook
attachment for exactly these models. WSL and Windows pass through unchanged,
since the offload block skips those platforms and probing there would be a code
path that never used to run.

peft imports symbols transformers no longer ships. Backfilling whole missing
modules was not enough: transformers 5.0.0.dev0 has conversion_mapping but not
_MODEL_TO_CONVERSION_PATTERN, so individual names are backfilled rather than a
real module being replaced wholesale.

On TRL 0.22.x a VLM skips dataset preparation and picks the vision collator from
_is_vlm alone. Fine-tuning a VLM on a text-only dataset then reaches the trainer
with a raw text column and transformers strips everything:

    ValueError: No columns in the dataset match the model's forward method
    signature ... The following columns have been ignored: [text]

Magistral_(24B)-Reasoning-Conversational fails exactly this way; it pins
trl==0.22.2 and trains a VLM on a plain text dataset. TRL 0.25.1+ fixed this by
keying off _is_vision_dataset, so that is what gets back-ported, and it no-ops
where TRL already defines the flag.

* Stop the dataclass backfill shadowing an inherited default, and say when a peft stand-in is not equivalent

Four things, all narrowing rather than widening.

_backfill_dataclass_defaults decided "no default yet" with `name not in
cls.__dict__`, which is class-local. A config subclass that re-annotates an
inherited field without assigning to it already has a default, through the MRO,
and None overwrote it. Reproduced directly: a child re-annotating a parent field
whose default is 7 came back None. dataclass reads defaults with getattr, so
hasattr is the test that matches what dataclass will do.

_backfill_missing_peft_symbols installs inert donors: an empty conversion
pattern, mapping lookups that return None. That is the truth on transformers 4
and on a transformers 5 that never had the symbol, and it is not the truth for a
transformers 5 that has conversions and renamed one, where peft would silently
skip work. Checked every released wheel from 5.0.0 to 5.6.0: all eleven names
are present, so this only fires on a dev build. It now warns for anything beyond
the pattern rather than repairing the import in silence.

The live control test observed PretrainedConfig before anything had imported
unsloth, so run on its own it had nothing to unwrap and skipped itself instead
of proving the failure is real. It installs the patch first now.

find_spec imports the parent packages, so `find_spec("trl.trainer.sft_trainer")`
raises rather than returning None when trl is absent, and the skipif marker
failed collection instead of skipping.

Tests: 4384 passed against 4377 on the same tree with these changes stashed,
same 1310 environment failures either way (version_compat and vllm_compat fetch
upstream sources per tag; the GPUs here are busy), and zero failures present on
this branch that are not also on its base.

* Load import_fixes by file spec in the dataclass backfill test

Importing unsloth.import_fixes goes through unsloth/__init__.py, which reaches
_gpu_init and pulls torch, numpy and unsloth_zoo: measured at 10.6s and 8288
modules. The helper under test is plain Python, and import_fixes.py has only
stdlib plus packaging at module level, so the file-spec load the adjacent
test_peft_symbol_backfill.py already uses works here too, at 0.01s with none of
that stack resident. A dependency-light run failed before reaching the code
under test; under a meta-path finder that raises on torch, numpy, unsloth_zoo,
unsloth, transformers, peft and trl, the three tests now pass.

* Correct four version claims in the compat comments

Comment-only. Checked against the published wheels and the commits that
introduced each change:

- kw_only=True on config dataclasses landed in 5.5.1, not 5.6.0. It was
  backported: 5.5.1 on the 5.5 branch, 5.6.0 on main, both from
  transformers#45139.
- The window needing the bare-annotation fix is 5.4.0 to 5.5.0. 5.0.0-5.3.0
  have no PretrainedConfig.__init_subclass__ at all.
- vllm/__init__.py is lazy at every tag from 0.11.2 on, so importing vllm
  never reaches transformers_utils.configs. The guard is still worth
  installing early, but not for the reason the comment gave.
- _is_vision_dataset landed in TRL 0.24.0, not 0.25.1 (trl#4080). 0.24.0's
  sft_trainer.py line is byte-identical to 0.25.1's, which is what the
  backport's no-op guard matches on.

* Move the live kw_only threshold to 5.5.1 as well

The previous commit corrected the comments but left this test asserting the
probe returns False until 5.6.0, so it would fail on 5.5.1 through 5.5.4,
which is exactly where the fix correctly stands down. Verified against the
extracted wheels: 5.4.0 and 5.5.0 have no kw_only=True in
configuration_utils.py, 5.5.1 and 5.6.0 both do.

* Fall back to the version window when the config source cannot be read

A stripped or frozen install ships bytecode only, and inspect.getsource then
raises. Answering False there patched a 5.5.1+ install that needs nothing, and
since the backfill runs before the upstream hook, fields upstream means to be
required keyword arguments would silently gain a None default.

Falls back to 5.4.0 <= v < 5.5.1, the only window with the ordering rule and
no kw_only=True. Half open so 5.5.0.post1 and other 5.5.0 rebuilds stay
inside. Readable source still decides, since the backport means the version
alone cannot see the truth. An unreadable or unparseable version stands down:
without positive evidence the install needs the patch, the better failure is
the loud TypeError the patch would have prevented, not a config that quietly
accepts a missing required field. Uses packaging's Version directly rather
than this module's wrapper, which raises on anything its regex misses and
rewrites pre-release suffixes upwards, moving dev builds across the edge.

* Apply ruff kwarg-spacing formatting

pre-commit.ci's ruff-format-with-kwargs hook reported "files were modified by
this hook" and could not push the result back, so the check stayed red.
Applied locally instead.

Three source-asserting tests had to be made robust rather than re-pinned,
since the reformatting is legitimate and will happen again:

- test_the_flag_is_recorded_beside_the_dtype counted the literal
  "self._autocast_enabled = (", which the formatter collapsed onto one line.
  Now pairs each _autocast_dtype initialiser with a nearby flag assignment by
  position, so it still catches a genuinely missing one.
- test_the_original_error_is_still_reported anchored on a whole f-string
  literal, which the formatter merged with the hint that follows it. Anchors
  on the message text alone now; either shape satisfies the intent.
- test_the_source_beats_the_version_fallback exposed a real fragility rather
  than a test problem: the detector matched the literal "kw_only=True", so any
  whitespace in transformers' own source would have read an install that needs
  nothing as one that needs patching. Made whitespace-tolerant.

* Detect the bare-annotation config rule by probing, not by version

`_transformers_needs_bare_annotation_fix` pinned the window to
`5.4.0 <= v < 5.5.1`. The rule arrived in 5.4.0 and `kw_only=True` retired it
in 5.5.1, but that landed as a backport on the 5.5 branch and on main
separately, so a version number mislabels any build that carries it early or
late, and says nothing about a future release that brings the rule back.

It now defines a throwaway subclass in exactly the shape vLLM uses, a default
followed by a bare annotation, and reports whether `__init_subclass__` raises.
Same answer on every version I checked, derived rather than tabulated:

    4.57.6  False      5.4.0  True       5.5.1  False
    5.3.0   False      5.5.0  True       5.6.0  False

The unreadable-source fallback keeps the same conservative default: without
positive evidence the install needs patching, answer False, since the better
failure is the loud TypeError the patch would have prevented rather than a
config that quietly accepts a missing required field.

The test that faked `transformers.__version__` to reach the fallback is
replaced. Faking a version deliberately no longer changes the answer, so that
is now asserted as the regression, alongside a test that a config whose
`__init_subclass__` raises is detected whatever it calls itself.

* Tighten the comments across the compatibility changes (#7866)

* Trim comments and docstrings across the transformers 5 / TRL compat changes

* Disable embedding offload on Windows and WSL for PR #7866

* Shorten comments and docstrings across the compat changes

* Gate the live bare-annotation test on the behaviour, not the version

---------

Co-authored-by: danielhanchen <unslothshared@gmail.com>
Co-authored-by: danielhanchen <unslothai@gmail.com>
2026-08-07 06:33:05 -07:00

166 lines
6.4 KiB
Python

"""The transformers-5 config fix, demonstrated against a real transformers 5.
transformers 5.x turns `PretrainedConfig` subclasses into dataclasses. vLLM's
`configs/deepseek_vl2.py` declares `vision_config: VisionEncoderConfig` with no
default, and a dataclass will not accept a non-default field after an inherited
default one ("TypeError: non-default argument 'vision_config' follows default
argument"). That fires while importing `vllm.transformers_utils.configs`, taking
down `import vllm` and with it `import unsloth`.
The other tests for this fix assert on source text; this one reproduces the
failing shape and checks the outcome, so it catches the fix silently ceasing to
work. No vLLM install needed: the config class above IS the reproduction. Skips
on transformers 4.x, where configs are not dataclasses.
"""
import pytest
transformers = pytest.importorskip("transformers")
from packaging.version import Version # noqa: E402
pytestmark = pytest.mark.skipif(
Version(transformers.__version__) < Version("5.0.0"),
reason = "transformers 4.x does not convert config subclasses to dataclasses",
)
def _build(tag):
"""A vLLM-shaped config pair: a bare annotation with no default."""
from transformers.configuration_utils import PretrainedConfig
class VisionEncoderConfig(PretrainedConfig):
model_type = f"vision_{tag}"
class DeepseekVL2Config(PretrainedConfig):
model_type = f"deepseek_vl_v2_{tag}"
vision_config: VisionEncoderConfig # no default: the trigger
return DeepseekVL2Config
@pytest.fixture
def unpatched():
"""Remove the patch so the failure can be observed, then restore it.
Imports unsloth first: run alone, nothing would have installed it yet."""
import unsloth # noqa: F401 - installs the patch we are about to remove
from transformers.configuration_utils import PretrainedConfig
saved = PretrainedConfig.__dict__.get("__init_subclass__")
flag = getattr(PretrainedConfig, "_unsloth_patched_init_subclass", False)
inner = getattr(saved, "__func__", saved)
original = getattr(inner, "__wrapped__", None)
if flag and original is not None:
PretrainedConfig.__init_subclass__ = classmethod(original)
PretrainedConfig._unsloth_patched_init_subclass = False
yield
if saved is not None:
PretrainedConfig.__init_subclass__ = saved
PretrainedConfig._unsloth_patched_init_subclass = flag
def test_the_failure_is_real_without_the_fix(unpatched):
"""Guards the premise: if this stops raising, the fix tests nothing."""
from unsloth.import_fixes import (
_transformers_configs_are_kw_only,
_transformers_needs_bare_annotation_fix,
fix_transformers5_bare_annotation_configs,
)
from transformers.configuration_utils import PretrainedConfig
if getattr(PretrainedConfig, "_unsloth_patched_init_subclass", False):
pytest.skip("could not unpatch; the wrapped original was not reachable")
if _transformers_configs_are_kw_only(PretrainedConfig):
pytest.skip(
f"transformers {transformers.__version__} passes kw_only=True "
f"(5.5.1+), so the ordering rule this fix works around is gone"
)
# The ordering rule only exists between 5.4.0 and 5.5.0: 5.0.0 to 5.3.x are
# 5.x but do not dataclass-ify configs at all (no `__init_subclass__`), so
# nothing raises there and the premise below does not apply. Ask the
# unpatched class rather than the version, which was the point of the probe.
if not _transformers_needs_bare_annotation_fix():
pytest.skip(
f"transformers {transformers.__version__} does not apply the "
f"dataclass ordering rule to config subclasses (pre-5.4.0)"
)
with pytest.raises(TypeError, match = "non-default argument"):
_build("unpatched")
def test_the_fix_stands_down_when_transformers_handles_it():
"""kw_only=True fixed this upstream, so patching anyway would be an untested
monkey patch. >= 5.5.1 covers both branches (5.5.1 on 5.5, 5.6.0 on main)."""
from unsloth.import_fixes import (
_transformers_configs_are_kw_only,
fix_transformers5_bare_annotation_configs,
)
from transformers.configuration_utils import PretrainedConfig
kw_only = _transformers_configs_are_kw_only(PretrainedConfig)
expected = Version(transformers.__version__) >= Version("5.5.1")
assert (
kw_only == expected
), f"transformers {transformers.__version__}: probe says kw_only={kw_only}"
if not kw_only:
pytest.skip("this transformers still needs the fix")
PretrainedConfig._unsloth_patched_init_subclass = False
fix_transformers5_bare_annotation_configs()
assert not getattr(PretrainedConfig, "_unsloth_patched_init_subclass", False)
def test_the_fix_lets_it_import():
from unsloth.import_fixes import fix_transformers5_bare_annotation_configs
fix_transformers5_bare_annotation_configs()
cls = _build("patched")
assert cls.__name__ == "DeepseekVL2Config"
def test_applying_twice_is_a_no_op():
from unsloth.import_fixes import fix_transformers5_bare_annotation_configs
from transformers.configuration_utils import PretrainedConfig
fix_transformers5_bare_annotation_configs()
first = PretrainedConfig.__dict__.get("__init_subclass__")
fix_transformers5_bare_annotation_configs()
assert PretrainedConfig.__dict__.get("__init_subclass__") is first
def test_ordinary_configs_are_unaffected():
"""The patch runs for EVERY config subclass, so it must disturb none."""
from unsloth.import_fixes import fix_transformers5_bare_annotation_configs
from transformers.configuration_utils import PretrainedConfig
fix_transformers5_bare_annotation_configs()
class Ordinary(PretrainedConfig):
model_type = "ordinary_probe"
def __init__(
self,
hidden_size = 16,
**kwargs,
):
self.hidden_size = hidden_size
super().__init__(**kwargs)
cfg = Ordinary(hidden_size = 32)
assert cfg.hidden_size == 32
assert cfg.model_type == "ordinary_probe"
def test_a_real_model_config_still_loads():
from unsloth.import_fixes import fix_transformers5_bare_annotation_configs
fix_transformers5_bare_annotation_configs()
from transformers import LlamaConfig
cfg = LlamaConfig(hidden_size = 64, num_hidden_layers = 2)
assert cfg.hidden_size == 64
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-q"]))