diff --git a/.github/workflows/consolidated-tests-ci.yml b/.github/workflows/consolidated-tests-ci.yml index 350d1ee400..bc7ab8a992 100644 --- a/.github/workflows/consolidated-tests-ci.yml +++ b/.github/workflows/consolidated-tests-ci.yml @@ -194,27 +194,28 @@ jobs: --deselect tests/test_unsloth_zoo_lora_merge.py::test_merge_lora_moves_cpu_inputs_to_active_device - name: unsloth_zoo — test_apply_fused_lm_head (lives in compiler.py) - # Not under tests/, so pytest's default discovery does not pick it - # up. Pure Python regex over transformers source strings; no GPU, - # no model download. Wall ~5-15 s, dominated by transformers import. - # - # The CUDA-spoof prelude mirrors tests/conftest.py:84-141: GH-hosted - # ubuntu-latest runners are GPU-less, and unsloth_zoo's __init__.py - # calls device_type.get_device_type() at module load, which raises - # NotImplementedError without an accelerator. We patch - # torch.cuda.is_available before the unsloth_zoo import so the - # cached @functools.cache-decorated get_device_type() captures - # "cuda" and the import chain finishes. The function under test - # is pure regex; spoofed CUDA presence has no effect on it. - # Env inherited from job block. + # `test_apply_fused_lm_head` lives at unsloth_zoo/compiler.py:1983, + # not under tests/, so pytest's default discovery does not pick it up. + # We route it through pytest by writing a one-shot shim test file + # inside the unsloth checkout's tests/ — pytest then walks UP and + # picks up tests/conftest.py, whose GPU-spoof harness (lines 84-141) + # patches torch.cuda.is_available, torch.cuda.memory.mem_get_info, + # torch.cuda.get_device_capability, and is_bf16_supported. That full + # spoof is required because unsloth_zoo/temporary_patches/gpt_oss.py + # at module load reads torch.cuda.memory.mem_get_info(0), which + # bare `is_available = True` doesn't cover. Env inherited. run: | - python <<'PY' - import torch - torch.cuda.is_available = lambda: True - from unsloth_zoo.compiler import test_apply_fused_lm_head - test_apply_fused_lm_head() - print("OK: test_apply_fused_lm_head") + set -euxo pipefail + cat > tests/_zoo_apply_fused_lm_head_shim.py <<'PY' + # Auto-generated by .github/workflows/consolidated-tests-ci.yml. + # Wraps unsloth_zoo.compiler.test_apply_fused_lm_head so that + # tests/conftest.py's GPU-spoof harness applies before the import. + from unsloth_zoo.compiler import test_apply_fused_lm_head as _zoo_test + def test_zoo_apply_fused_lm_head_runs(): + _zoo_test() PY + python -m pytest -q --tb=short tests/_zoo_apply_fused_lm_head_shim.py + rm -f tests/_zoo_apply_fused_lm_head_shim.py - name: Static checks — unsloth/trainer.py + unsloth/models/rl.py against latest pip TRL # AST-only sanity: confirm both files parse and that every TRL symbol @@ -297,28 +298,24 @@ jobs: print(f"hf_utils.py public surface ({len(public)}): " + ", ".join(public)) PY - - name: Runtime checks — invoke every zero-arg patch_* across both repos - # The user asked to confirm patch_* functions actually work, not just - # exist. Two layers: - # 1. Symbol presence: hasattr(module, name) — covers both direct - # definitions AND re-exports (patch_unsloth_smart_gradient_checkpointing - # is re-exported from unsloth/models/_utils.py:138 from - # unsloth_zoo/gradient_checkpointing.py:906). - # 2. Runtime invocation: every patch_* whose required parameters are - # all defaulted is called and the result type is reported. Most - # "patch_*" hooks return None on success and raise on failure; a - # handful return bool. A failure here means transformers/torch/trl - # drift broke a hook. Locally validated 50/51 succeed against the - # workspace's pinned deps (the one failure is a real bug surfaced - # by this check: unsloth.models._utils.patch_fast_lora raises - # NameError because fast_lora_forward is referenced unbound). - # The check explicitly does NOT fail on per-patch errors -- the whole - # job runs continue-on-error: true for the first pass anyway, and we - # want the full failure ledger in the log, not just the first one. + - name: Runtime checks — invoke every zero-arg patch_* across both repos (via pytest shim) + # Routed through pytest so tests/conftest.py's GPU-spoof harness + # applies before any unsloth_zoo.temporary_patches.* import. + # Locally validated 50/51 zero-arg patches succeed; the lone failure + # surfaces a real bug (unsloth.models._utils.patch_fast_lora raises + # NameError: name 'fast_lora_forward' is not defined). The shim + # reports the full ledger but only fails when one of the two + # `required` helpers is absent. run: | set -euxo pipefail - python <<'PY' - import importlib, inspect, sys + cat > tests/_runtime_patch_check_shim.py <<'PY' + # Auto-generated by .github/workflows/consolidated-tests-ci.yml. + # Wraps the runtime patch_* validation into a pytest test so the + # tests/conftest.py GPU-spoof harness applies. continue-on-error + # at the workflow level catches per-patch failures; this shim only + # asserts that the two `required` helpers are reachable. + import importlib, inspect + MODULES = [ "unsloth.models._utils", "unsloth.models.rl", "unsloth.import_fixes", "unsloth.kernels.cross_entropy_loss", "unsloth.kernels.rms_layernorm", @@ -336,67 +333,70 @@ jobs: "unsloth_zoo.temporary_patches.bitsandbytes", "unsloth_zoo.temporary_patches.flex_attention_bwd", ] - required = { + REQUIRED = { "patch_unsloth_smart_gradient_checkpointing", "patch_gradient_accumulation_fix", } - ok, fail, args, miss_imports = 0, [], [], {} - seen_required = set() - for mod_name in MODULES: - try: - mod = importlib.import_module(mod_name) - except Exception as e: - miss_imports[mod_name] = f"{type(e).__name__}: {e}" - continue - for name in sorted(dir(mod)): - if not name.startswith("patch_"): continue - fn = getattr(mod, name, None) - if not callable(fn): continue - if name in required: seen_required.add(name) - try: - sig = inspect.signature(fn) - need = [p.name for p in sig.parameters.values() - if p.default is inspect.Parameter.empty - and p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, - inspect.Parameter.POSITIONAL_ONLY)] - except (TypeError, ValueError): - need = [] - if need: - args.append((mod_name, name, need)); continue - try: - fn() - ok += 1 - print(f" OK {mod_name}.{name}") - except Exception as e: - fail.append((mod_name, name, type(e).__name__, str(e)[:200])) - print(f" FAIL {mod_name}.{name} -> {type(e).__name__}: {str(e)[:200]}") - print(f"\nzero-arg patch_*: ok={ok} fail={len(fail)}") - print(f"arg-required patch_*: {len(args)}") - for m, n, r in args: - print(f" needs={r}: {m}.{n}") - if miss_imports: - print("\nmodules failed to import (skipped):") - for k, v in miss_imports.items(): - print(f" {k}: {v}") - missing_required = required - seen_required - if missing_required: - print(f"\n::error::required patch_* helpers MISSING: {sorted(missing_required)}") - sys.exit(1) - print(f"\nrequired patch_* helpers present: {sorted(seen_required)}") - PY - - name: Runtime checks — patch_tiled_mlp on a synthetic MLP module - # Build a minimal nn.Module with the gate_proj/up_proj/down_proj - # surface the tiled MLP patcher expects, then apply patch_tiled_mlp - # and confirm the forward still produces the same output (within - # numerical tolerance). No real model download. + def test_zero_arg_patch_invocations(): + ok, fail, args, miss_imports = 0, [], [], {} + seen_required = set() + for mod_name in MODULES: + try: + mod = importlib.import_module(mod_name) + except Exception as e: + miss_imports[mod_name] = f"{type(e).__name__}: {e}" + continue + for name in sorted(dir(mod)): + if not name.startswith("patch_"): continue + fn = getattr(mod, name, None) + if not callable(fn): continue + if name in REQUIRED: seen_required.add(name) + try: + sig = inspect.signature(fn) + need = [p.name for p in sig.parameters.values() + if p.default is inspect.Parameter.empty + and p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.POSITIONAL_ONLY)] + except (TypeError, ValueError): + need = [] + if need: + args.append((mod_name, name, need)); continue + try: + fn() + ok += 1 + print(f" OK {mod_name}.{name}") + except Exception as e: + fail.append((mod_name, name, type(e).__name__, str(e)[:200])) + print(f" FAIL {mod_name}.{name} -> {type(e).__name__}: {str(e)[:200]}") + print(f"\nzero-arg patch_*: ok={ok} fail={len(fail)}") + print(f"arg-required patch_* (skipped, listed for review): {len(args)}") + for m, n, r in args: + print(f" needs={r}: {m}.{n}") + if miss_imports: + print("\nmodules failed to import (skipped):") + for k, v in miss_imports.items(): + print(f" {k}: {v}") + print(f"required patch_* helpers seen: {sorted(seen_required)}") + missing = REQUIRED - seen_required + assert not missing, f"required patch_* helpers MISSING: {sorted(missing)}" + PY + python -m pytest -q --tb=short tests/_runtime_patch_check_shim.py -s + rm -f tests/_runtime_patch_check_shim.py + + - name: Runtime checks — patch_tiled_mlp on a synthetic MLP module (via pytest shim) + # Same shim pattern: pytest picks up tests/conftest.py before importing + # unsloth_zoo.tiled_mlp, so the GPU-spoof harness covers + # unsloth_zoo.temporary_patches.gpt_oss's mem_get_info call. run: | set -euxo pipefail - python <<'PY' - import torch, torch.nn as nn + cat > tests/_tiled_mlp_check_shim.py <<'PY' + # Auto-generated by .github/workflows/consolidated-tests-ci.yml. + import torch + import torch.nn as nn from unsloth_zoo.tiled_mlp import patch_tiled_mlp, patch_mlp - class MLP(nn.Module): + class _MLP(nn.Module): def __init__(self, hidden=64, intermediate=128): super().__init__() self.gate_proj = nn.Linear(hidden, intermediate, bias=False) @@ -406,33 +406,31 @@ jobs: def forward(self, x): return self.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x)) - class FakeModel(nn.Module): + class _FakeModel(nn.Module): def __init__(self): super().__init__() - self.layers = nn.ModuleList([nn.ModuleDict({"mlp": MLP()}) - for _ in range(2)]) + self.layers = nn.ModuleList([nn.ModuleDict({"mlp": _MLP()}) for _ in range(2)]) def forward(self, x): for layer in self.layers: x = x + layer["mlp"](x) return x - torch.manual_seed(0) - m = FakeModel().eval() - x = torch.randn(2, 4, 64) - with torch.no_grad(): - y_before = m(x).clone() - # patch_mlp on a single MLP module is the unit-level entry point. - patch_mlp(m.layers[0]["mlp"]) - # patch_tiled_mlp walks a model and replaces matching MLPs. - patch_tiled_mlp(m) - with torch.no_grad(): - y_after = m(x).clone() - # tiled MLP should be numerically equivalent to the eager path. - err = (y_before - y_after).abs().max().item() - print(f"patch_tiled_mlp output diff = {err:.3e}") - assert err < 1e-3, f"tiled MLP output drifted: {err}" - print("OK: patch_tiled_mlp + patch_mlp") + def test_patch_tiled_mlp_numerical_equivalence(): + torch.manual_seed(0) + m = _FakeModel().eval() + x = torch.randn(2, 4, 64) + with torch.no_grad(): + y_before = m(x).clone() + patch_mlp(m.layers[0]["mlp"]) + patch_tiled_mlp(m) + with torch.no_grad(): + y_after = m(x).clone() + err = (y_before - y_after).abs().max().item() + print(f"patch_tiled_mlp output diff = {err:.3e}") + assert err < 1e-3, f"tiled MLP output drifted: {err}" PY + python -m pytest -q --tb=short tests/_tiled_mlp_check_shim.py -s + rm -f tests/_tiled_mlp_check_shim.py - name: llama.cpp install + `llama-cli --help` smoke # The user asked to confirm llama.cpp installs and the CLI runs.