diff --git a/.github/workflows/consolidated-tests-ci.yml b/.github/workflows/consolidated-tests-ci.yml index 0e354ca75a..b8ca13039b 100644 --- a/.github/workflows/consolidated-tests-ci.yml +++ b/.github/workflows/consolidated-tests-ci.yml @@ -599,6 +599,329 @@ jobs: python -m pytest -q --tb=short tests/_tiled_mlp_check_shim.py -s rm -f tests/_tiled_mlp_check_shim.py + - name: Compiler cache hygiene + source-rewriter invariants (synthetic inputs) + # Lightweight pipeline coverage for unsloth_zoo.compiler. Pure regex + # / tokenize / ast paths driven by tiny synthetic source strings: + # - higher_precision_softmax (basic + idempotent) + # - fix_rotary_embedding_dtype (no-op + active under + # UNSLOTH_FORCE_CUSTOM_DTYPE) + # - fix_attention_dtype_consistency (insert + idempotent) + # - convert_attention_masks_to_bool (rewrite + no-op) + # - create_new_function happy-path (versioning block, license + # header, AST parse, importlib re-import) + # - create_new_function **kwargs collision (exercises + # _rewrite_kwargs_param + _insert_kwargs_alias) + # - UNSLOTH_COMPILE_OVERWRITE=0 forced-recompile on transformers + # version mismatch (compiler.py:947-963) + # - matching short-circuit when versions are equal + # No real transformers modeling module is loaded; complements the + # heavier real-class round-trip step below. Wall-time ~10-25s. + run: | + set -euxo pipefail + cat > tests/_compiler_cache_invariants_shim.py <<'PY' + # Auto-generated by .github/workflows/consolidated-tests-ci.yml. + # Cache-hygiene + source-rewriter invariants for unsloth_zoo.compiler. + import sys, pathlib, os, ast, importlib, importlib.util, time + sys.path.insert(0, str(pathlib.Path(__file__).parent)) + import _zoo_aggressive_cuda_spoof as _spoof + _spoof.apply() + import pytest + import torch # noqa: F401 (compiler.py imports torch at module load) + + + def _isolate_cache(tmp_path, monkeypatch): + """Point UNSLOTH_COMPILE_LOCATION at tmp_path and reset module + globals. The compiler.py global is captured at module load + (line 75/179), so we delete + reimport per test.""" + monkeypatch.setenv("UNSLOTH_COMPILE_LOCATION", str(tmp_path)) + if "unsloth_zoo.compiler" in sys.modules: + del sys.modules["unsloth_zoo.compiler"] + import unsloth_zoo.compiler as compiler + compiler.UNSLOTH_COMPILE_LOCATION = str(tmp_path) + compiler.UNSLOTH_COMPILE_USE_TEMP = False + return compiler + + + def test_higher_precision_softmax_basic_and_idempotent(tmp_path, monkeypatch): + c = _isolate_cache(tmp_path, monkeypatch) + src = ( + "y = nn.functional.softmax(x, dim=-1)\n" + "z = F.softmax(a, dim=1, dtype=torch.bfloat16)\n" + ) + out = c.higher_precision_softmax(src) + assert "dtype = torch.float32).to(x.dtype)" in out + assert "dtype = torch.float32).to(a.dtype)" in out + assert c.higher_precision_softmax(out) == out + + + def test_fix_rotary_dtype_no_op_without_env(tmp_path, monkeypatch): + c = _isolate_cache(tmp_path, monkeypatch) + monkeypatch.delenv("UNSLOTH_FORCE_CUSTOM_DTYPE", raising=False) + src = "out = cos.to(dtype=x.dtype) + sin.to(dtype=x.dtype)\n" + assert c.fix_rotary_embedding_dtype(src) == src + + + def test_fix_rotary_dtype_active(tmp_path, monkeypatch): + c = _isolate_cache(tmp_path, monkeypatch) + monkeypatch.setenv( + "UNSLOTH_FORCE_CUSTOM_DTYPE", + "float16;torch.float32;torch.bfloat16;torch.float16;pass", + ) + monkeypatch.setenv("UNSLOTH_FORCE_FLOAT32", "1") + src = "out = cos.to(dtype=x.dtype) + sin.to(dtype=x.dtype)\n" + out = c.fix_rotary_embedding_dtype(src) + # Active form rewrites cos.to / sin.to. Either the conditional + # form or the cast form is acceptable -- different transformers + # versions surface slightly different outputs from the rewriter. + assert "cos.to(dtype=x.dtype)" not in out + assert "sin.to(dtype=x.dtype)" not in out + + + def test_fix_attention_dtype_consistency_insert_then_idempotent(tmp_path, monkeypatch): + c = _isolate_cache(tmp_path, monkeypatch) + src = ( + " query_states, key_states = apply_rotary_pos_emb(" + "query_states, key_states, cos, sin)\n" + " attn = q @ k.T\n" + ) + out = c.fix_attention_dtype_consistency(src) + assert out.count("value_states = value_states.to(query_states.dtype)") == 1 + assert c.fix_attention_dtype_consistency(out) == out + + + def test_convert_attention_masks_to_bool_rewrites(tmp_path, monkeypatch): + c = _isolate_cache(tmp_path, monkeypatch) + src = ( + "def make_mask(x):\n" + " out = torch.finfo(x.dtype).min * x\n" + " return out\n" + ) + out = c.convert_attention_masks_to_bool("make_mask", src) + # Loose match: rewriter inserts a `!=torch.finfo(...).min` check + # somewhere on the return path. Tightening to an exact + # last-line match is brittle across transformers versions. + assert "!=torch.finfo" in out + + + def test_convert_attention_masks_to_bool_no_op(tmp_path, monkeypatch): + c = _isolate_cache(tmp_path, monkeypatch) + src = "def make_mask(x):\n return x\n" + assert c.convert_attention_masks_to_bool("make_mask", src) == src + + + def _versioning_lines(file_text): + """Extract the four version strings from the versioning block.""" + assert file_text.startswith('"""\n'), "missing opening triple-quote" + head = file_text.split("__UNSLOTH_VERSIONING__", 1)[0] + lines = [ln for ln in head.splitlines() if ln and ln != '"""'] + return lines + + + def test_create_new_function_happy_path(tmp_path, monkeypatch): + c = _isolate_cache(tmp_path, monkeypatch) + src = "def f(x):\n return nn.functional.softmax(x, dim=-1)\n" + c.create_new_function( + name="f_happy", new_source=src, model_location="builtins", + functions=[], overwrite=True, + ) + cached = tmp_path / "f_happy.py" + assert cached.exists() + text = cached.read_text(encoding="utf-8") + versions = _versioning_lines(text) + assert len(versions) == 4, versions + assert text.count(c._full_license_header) == 1 + ast.parse(text) + spec = importlib.util.spec_from_file_location("f_happy_reimport", cached) + m2 = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m2) + assert callable(m2.f) + import inspect as _inspect + # higher_precision_softmax should have promoted to float32. + assert "dtype = torch.float32" in _inspect.getsource(m2.f) + + + def test_create_new_function_overwrite_zero_recompiles_on_version_mismatch( + tmp_path, monkeypatch, + ): + c = _isolate_cache(tmp_path, monkeypatch) + name = "vmismatch" + cached = tmp_path / f"{name}.py" + stub = ( + '"""\n0.0.0\n0.0.0\n0.0.0-stub\n0.0.0\n__UNSLOTH_VERSIONING__\n"""\n' + + c._full_license_header + + "def vmismatch(x):\n return x\n" + ) + cached.write_text(stub, encoding="utf-8") + monkeypatch.setenv("UNSLOTH_COMPILE_OVERWRITE", "0") + src = "def vmismatch(x):\n return x + 1\n" + c.create_new_function( + name=name, new_source=src, model_location="builtins", + functions=[], overwrite=False, + ) + text = cached.read_text(encoding="utf-8") + assert "0.0.0-stub" not in text, ( + "OVERWRITE=0 + transformers-version-mismatch did NOT recompile" + ) + versions = _versioning_lines(text) + import importlib.metadata as _md + assert versions[2] == _md.version("transformers") + + + def test_create_new_function_overwrite_zero_short_circuits_when_versions_match( + tmp_path, monkeypatch, + ): + c = _isolate_cache(tmp_path, monkeypatch) + name = "vmatch" + src = "def vmatch(x):\n return x\n" + c.create_new_function( + name=name, new_source=src, model_location="builtins", + functions=[], overwrite=True, + ) + cached = tmp_path / f"{name}.py" + mtime_before = cached.stat().st_mtime_ns + time.sleep(0.05) + monkeypatch.setenv("UNSLOTH_COMPILE_OVERWRITE", "0") + c.create_new_function( + name=name, new_source=src, model_location="builtins", + functions=[], overwrite=False, + ) + assert cached.stat().st_mtime_ns == mtime_before, ( + "OVERWRITE=0 + matching versions should NOT rewrite the file" + ) + PY + python -m pytest -q --tb=short tests/_compiler_cache_invariants_shim.py + rm -f tests/_compiler_cache_invariants_shim.py + + - name: Compiler real-class round-trip (llama / qwen3 / gemma3 + SFT trainer) + # Heavier complementary path to the cache-hygiene step above. + # Calls `unsloth_compile_transformers(model_type=...)` against + # actual transformers modeling modules and `_patch_trl_rl_trainers` + # against TRL's SFTTrainer, then ast.parse / importlib-load / + # introspect the generated unsloth_compiled_cache/*.py files. + # Catches regex / source-rewriter drift across the matrix's + # (transformers, trl) combination -- the dominant failure mode of + # `unsloth_compile_transformers` after a transformers point release. + # Hermetic cache dir per pytest invocation; we override the + # job-level UNSLOTH_COMPILE_DISABLE=1 inside the shim so + # compilation actually runs here. Wall-time ~2-3 min. + run: | + set -euxo pipefail + cat > tests/_zoo_compiler_cache_shim.py <<'PY' + # Auto-generated by .github/workflows/consolidated-tests-ci.yml. + import os, sys, ast, pathlib, importlib.util, tempfile + _HERE = pathlib.Path(__file__).parent + sys.path.insert(0, str(_HERE)) + import _zoo_aggressive_cuda_spoof as _spoof + _spoof.apply() + + # Hermetic cache dir + force compile path BEFORE importing + # unsloth_zoo.compiler (its globals capture env at module load). + _CACHE = pathlib.Path(tempfile.mkdtemp(prefix="unsloth_cache_")) + os.environ["UNSLOTH_COMPILE_LOCATION"] = str(_CACHE) + os.environ["UNSLOTH_COMPILE_OVERWRITE"] = "1" + os.environ.pop("UNSLOTH_COMPILE_DISABLE", None) + + import pytest + from unsloth_zoo.compiler import unsloth_compile_transformers + + + def _verify_file(path: pathlib.Path, must_expose): + assert path.exists(), f"compiler did not write {path}" + src = path.read_text(encoding="utf-8") + ast.parse(src, filename=str(path)) + spec = importlib.util.spec_from_file_location(path.stem, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + for name in must_expose: + assert hasattr(mod, name), ( + f"{path.name} missing expected attr {name!r}; " + f"found: {sorted(n for n in dir(mod) if not n.startswith('_'))[:25]}" + ) + + + @pytest.mark.parametrize("model_type,rms_class", [ + ("llama", "LlamaRMSNorm"), + ("qwen3", "Qwen3RMSNorm"), + ("gemma3", "Gemma3RMSNorm"), + ]) + def test_compile_real_modeling_module(model_type, rms_class): + import importlib as _il + try: + _il.import_module( + f"transformers.models.{model_type}.modeling_{model_type}" + ) + except ModuleNotFoundError: + pytest.skip( + f"transformers build lacks model_type={model_type}" + ) + # fast_lora_forwards=False: the LoRA path expects PEFT + a real + # device for some torch.compile builds; skip it here, the + # source-emission path is what we want to verify. + unsloth_compile_transformers( + model_type=model_type, fast_lora_forwards=False, + ) + modeling = _il.import_module( + f"transformers.models.{model_type}.modeling_{model_type}" + ) + assert getattr(modeling, "__UNSLOTH_PATCHED__", False) is True + combined = _CACHE / f"unsloth_compiled_module_{model_type}.py" + _verify_file(combined, must_expose=[rms_class]) + + + def test_compile_disable_writes_nothing(): + """Negative control: when UNSLOTH_COMPILE_DISABLE=1 the + compile path must early-return without producing new files.""" + os.environ["UNSLOTH_COMPILE_DISABLE"] = "1" + try: + before = set(_CACHE.iterdir()) + # Pick a model_type that still resolves on this transformers. + for mt in ("llama", "mistral", "qwen2"): + try: + import importlib as _il + _il.import_module( + f"transformers.models.{mt}.modeling_{mt}" + ) + break + except ModuleNotFoundError: + continue + else: + pytest.skip("no probe model_type available") + unsloth_compile_transformers( + model_type=mt, fast_lora_forwards=False, + ) + after = set(_CACHE.iterdir()) + assert after == before, ( + f"DISABLE=1 still wrote: {[p.name for p in after - before]}" + ) + finally: + os.environ.pop("UNSLOTH_COMPILE_DISABLE", None) + + + def test_compile_sft_trainer_patch(): + """Round-trip TRL's SFTTrainer through the rl.py patch path + and verify the generated UnslothSFTTrainer.py.""" + pytest.importorskip("trl") + try: + from unsloth.models.rl import _patch_trl_rl_trainers + except ImportError: + pytest.skip("unsloth.models.rl._patch_trl_rl_trainers absent") + try: + _patch_trl_rl_trainers("sft_trainer") + except Exception as e: + # TRL 1.x renames break the patch helper internally; we + # accept that here and skip rather than fail the cell. + pytest.skip(f"_patch_trl_rl_trainers raised: {type(e).__name__}: {e}") + sft = _CACHE / "UnslothSFTTrainer.py" + if not sft.exists(): + pytest.skip( + "_patch_trl_rl_trainers ran but did not emit " + "UnslothSFTTrainer.py on this TRL version." + ) + _verify_file(sft, must_expose=["UnslothSFTTrainer"]) + PY + python -m pytest -q --tb=short tests/_zoo_compiler_cache_shim.py + rm -f tests/_zoo_compiler_cache_shim.py + - name: llama.cpp install via unsloth_zoo.llama_cpp + `llama-cli --help` smoke # Exercise the canonical `unsloth_zoo.llama_cpp.install_llama_cpp` # flow that GGUF export uses at runtime: clone ggml-org/llama.cpp