unsloth/tests/test_gemma4_moe_4bit_swap.py
2026-05-15 03:50:06 +00:00

147 lines
5.4 KiB
Python

"""Unit tests for the Gemma-4 MoE per-expert Linear4bit swap (#5344).
End-to-end correctness on the real 26B-A4B checkpoint requires a GPU + the
checkpoint on disk, so this file restricts itself to fast CPU-only tests
that exercise the swap helper's shape contract, idempotence, and gating
behaviour. The full repro (resident VRAM 46 GB -> 14.27 GB, cosine sim 0.994
vs BF16) is documented in the PR description.
"""
import importlib
import os
import torch
import torch.nn as nn
def _stub_gemma4_module():
"""Construct a stub Gemma4TextExperts-like module without importing
transformers' Gemma4Config (which would force a fresh transformers
download in CPU-only CI)."""
try:
from transformers.models.gemma4.modeling_gemma4 import Gemma4TextExperts
except Exception:
return None
# The class init requires a config; build a tiny synthetic one and then
# overwrite the fused weights with shapes small enough for CPU tests.
class _StubConfig:
num_experts = 4
hidden_size = 16
moe_intermediate_size = 8
hidden_activation = "gelu_pytorch_tanh"
module = Gemma4TextExperts.__new__(Gemma4TextExperts)
nn.Module.__init__(module)
module.num_experts = _StubConfig.num_experts
module.hidden_dim = _StubConfig.hidden_size
module.intermediate_dim = _StubConfig.moe_intermediate_size
module.gate_up_proj = nn.Parameter(
torch.randn(
_StubConfig.num_experts,
2 * _StubConfig.moe_intermediate_size,
_StubConfig.hidden_size,
dtype = torch.bfloat16,
),
requires_grad = False,
)
module.down_proj = nn.Parameter(
torch.randn(
_StubConfig.num_experts,
_StubConfig.hidden_size,
_StubConfig.moe_intermediate_size,
dtype = torch.bfloat16,
),
requires_grad = False,
)
from transformers.activations import ACT2FN
module.act_fn = ACT2FN[_StubConfig.hidden_activation]
return module
def test_is_enabled_reads_env_var():
from unsloth.models import gemma4_moe_4bit
old = os.environ.pop("UNSLOTH_GEMMA4_MOE_4BIT", None)
try:
assert gemma4_moe_4bit.is_gemma4_moe_4bit_enabled() is False
os.environ["UNSLOTH_GEMMA4_MOE_4BIT"] = "1"
assert gemma4_moe_4bit.is_gemma4_moe_4bit_enabled() is True
os.environ["UNSLOTH_GEMMA4_MOE_4BIT"] = "0"
assert gemma4_moe_4bit.is_gemma4_moe_4bit_enabled() is False
finally:
if old is None:
os.environ.pop("UNSLOTH_GEMMA4_MOE_4BIT", None)
else:
os.environ["UNSLOTH_GEMMA4_MOE_4BIT"] = old
def test_swap_skips_models_without_gemma4_experts():
from unsloth.models.gemma4_moe_4bit import (
swap_gemma4_experts_to_per_expert_linear4bit,
)
model = nn.Sequential(nn.Linear(8, 8), nn.Linear(8, 8))
assert swap_gemma4_experts_to_per_expert_linear4bit(model) == 0
def test_swap_skips_when_transformers_lacks_gemma4():
"""If transformers does not expose Gemma4TextExperts, the helper must
return 0 without raising. We simulate the ImportError by patching."""
import unsloth.models.gemma4_moe_4bit as g4m
real_import = importlib.import_module
def _broken_import(name, *args, **kwargs):
if name == "transformers.models.gemma4.modeling_gemma4":
raise ImportError("simulated absence")
return real_import(name, *args, **kwargs)
try:
importlib.import_module = _broken_import
# Re-exercise via the public helper. It imports Gemma4TextExperts
# inside its try/except, so the simulated ImportError must yield 0.
model = nn.Sequential(nn.Linear(8, 8))
assert g4m.swap_gemma4_experts_to_per_expert_linear4bit(model) == 0
finally:
importlib.import_module = real_import
def test_swap_idempotent_on_stub_module_without_cuda():
"""On CPU we cannot exercise bnb (Linear4bit requires CUDA). Verify the
helper at least returns 0 for the no-bnb-experts case without raising,
and is idempotent across repeated calls."""
from unsloth.models.gemma4_moe_4bit import (
swap_gemma4_experts_to_per_expert_linear4bit,
)
if not torch.cuda.is_available():
# CPU-only: bnb's Linear4bit init would fail. Validate the model-walk
# path on an empty Sequential to confirm the helper is side-effect-free.
model = nn.Sequential(nn.Linear(4, 4))
assert swap_gemma4_experts_to_per_expert_linear4bit(model) == 0
assert swap_gemma4_experts_to_per_expert_linear4bit(model) == 0
return
# GPU path: build the stub and run a real swap.
module = _stub_gemma4_module()
if module is None:
return # transformers without gemma4 module: nothing to test
model = nn.Sequential(module.to("cuda"))
n1 = swap_gemma4_experts_to_per_expert_linear4bit(model)
n2 = swap_gemma4_experts_to_per_expert_linear4bit(model)
assert n1 == 1
assert n2 == 0 # idempotent: already-swapped modules are skipped
assert hasattr(module, "gate_up_proj_4bit")
assert hasattr(module, "down_proj_4bit")
assert len(module.gate_up_proj_4bit) == module.num_experts
assert len(module.down_proj_4bit) == module.num_experts
if __name__ == "__main__":
test_is_enabled_reads_env_var()
test_swap_skips_models_without_gemma4_experts()
test_swap_skips_when_transformers_lacks_gemma4()
test_swap_idempotent_on_stub_module_without_cuda()
print("All 4 swap tests passed.")